Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, test } from "../test/runtime";
import { calculateCnhFirstVerifier } from "./calculate-cnh-first-verifier";

describe("calculateCnhFirstVerifier", () => {
test("should calculate the first verifier without decrement", () => {
expect(calculateCnhFirstVerifier("000000001")).toEqual({ firstVerifier: 1, decrement: 0 });
});

test("should calculate the first verifier with decrement when remainder is 10 or more", () => {
expect(calculateCnhFirstVerifier("000000093")).toEqual({ firstVerifier: 0, decrement: 2 });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type CnhFirstVerifier = {
/** The first check digit, 0 to 9. */
firstVerifier: number;
/** The correction to apply to the second verifier, 0 or 2. */
decrement: number;
};

/**
* Calculates the first verification digit of a Brazilian CNH (Carteira Nacional de Habilitação)
* from its 9-digit base number.
*
* @param {string} base - The 9-digit CNH base number.
* @returns {CnhFirstVerifier} The first verification digit and the decrement that must be
* applied when calculating the second verification digit.
*
* @example
* ```typescript
* calculateCnhFirstVerifier("000000093"); // { firstVerifier: 0, decrement: 2 }
* ```
*/
export const calculateCnhFirstVerifier = (base: string): CnhFirstVerifier => {
let sum = 0;

for (let i = 0; i < 9; i++) {
sum += (base.charCodeAt(i) - 48) * (9 - i);
}

const remainder = sum % 11;

if (remainder >= 10) return { firstVerifier: 0, decrement: 2 };

return { firstVerifier: remainder, decrement: 0 };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, test } from "../test/runtime";
import { calculateCnhSecondVerifier } from "./calculate-cnh-second-verifier";

describe("calculateCnhSecondVerifier", () => {
test("should calculate the second verifier", () => {
expect(calculateCnhSecondVerifier({ base: "000000001", decrement: 0 })).toBe(9);
});

test("should wrap around when the decrement makes the result negative", () => {
expect(calculateCnhSecondVerifier({ base: "000000093", decrement: 2 })).toBe(9);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type CalculateCnhSecondVerifierParams = {
/** The 9 digit CNH registry number. */
base: string;
/** The correction the first verifier reported, 0 or 2. */
decrement: number;
};

/**
* Calculates the second verification digit of a Brazilian CNH (Carteira Nacional de Habilitação)
* from its 9-digit base number and the decrement produced by `calculateCnhFirstVerifier`.
*
* @param {CalculateCnhSecondVerifierParams} params - The calculation parameters.
* @param {string} params.base - The 9-digit CNH base number.
* @param {number} params.decrement - The decrement calculated alongside the first verification digit.
* @returns {number} The calculated second verification digit (0-9).
*
* @example
* ```typescript
* calculateCnhSecondVerifier({ base: "000000093", decrement: 2 }); // 9
* ```
*/
export const calculateCnhSecondVerifier = ({
base,
decrement,
}: CalculateCnhSecondVerifierParams): number => {
let sum = 0;

for (let i = 0; i < 9; i++) {
sum += (base.charCodeAt(i) - 48) * (i + 1);
}

let secondVerifier = (sum % 11) - decrement;

if (secondVerifier < 0) secondVerifier += 11;

if (secondVerifier >= 10) secondVerifier = 0;

return secondVerifier;
};
2 changes: 2 additions & 0 deletions src/_internals/constants/cep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Digits of a CEP. */
export const CEP_LENGTH = 8;
2 changes: 2 additions & 0 deletions src/_internals/constants/cpf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Digits of a CPF. */
export const CPF_LENGTH = 11;
7 changes: 7 additions & 0 deletions src/_internals/constants/pis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** Digits of a PIS (Programa de Integração Social) number. */
export const PIS_LENGTH = 11;

/**
* Weights used to calculate the PIS (Programa de Integração Social) check digit.
*/
export const PIS_WEIGHTS = [3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
2 changes: 2 additions & 0 deletions src/_internals/constants/processo-juridico.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Digits of a processo jurídico number (`NNNNNNNDDAAAAJTROOOO`, Resolução CNJ nº 65/2008). */
export const PROCESSO_JURIDICO_LENGTH = 20;
74 changes: 63 additions & 11 deletions src/_internals/fetch-with-retry/fetch-with-retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "../test/runtime";
import { fetchWithRetry, isRetryableFetchError } from "./fetch-with-retry";
import { fetchWithRetry } from "./fetch-with-retry";

describe("fetchWithRetry", () => {
const originalFetch = globalThis.fetch;
Expand All @@ -26,33 +26,85 @@ describe("fetchWithRetry", () => {
status: 200,
});

globalThis.fetch = fetchMock as unknown as typeof fetch;
globalThis.fetch = fetchMock;

const response = await fetchWithRetry("https://example.com", { retries: 1, retryDelayMs: 0 });

expect(response.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not retry when the cause object has no code", async () => {
const error = Object.assign(new TypeError("random failure"), { cause: {} });
const fetchMock = vi.fn().mockRejectedValue(error);

globalThis.fetch = fetchMock;

await expect(
fetchWithRetry("https://example.com", { retries: 3, retryDelayMs: 0 }),
).rejects.toThrow(error);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("does not retry non-transient failures", async () => {
const error = new Error("Invalid URL");
const fetchMock = vi.fn().mockRejectedValue(error);

globalThis.fetch = fetchMock as unknown as typeof fetch;
globalThis.fetch = fetchMock;

await expect(
fetchWithRetry("https://example.com", { retries: 3, retryDelayMs: 0 }),
).rejects.toThrow(error);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("detects retryable undici errors by code", () => {
expect(
isRetryableFetchError(
Object.assign(new TypeError("fetch failed"), {
cause: { code: "UND_ERR_SOCKET" },
}),
),
).toBe(true);
it("retries when a top level error code is transient", async () => {
const fetchMock = vi
.fn()
.mockRejectedValueOnce(Object.assign(new Error("boom"), { code: "ECONNRESET" }))
.mockResolvedValueOnce({ ok: true, status: 200 });
globalThis.fetch = fetchMock;

await fetchWithRetry("https://example.com", { retryDelayMs: 0 });

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("retries when the error message says fetch failed", async () => {
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new TypeError("fetch failed"))
.mockResolvedValueOnce({ ok: true, status: 200 });
globalThis.fetch = fetchMock;

await fetchWithRetry("https://example.com", { retryDelayMs: 0 });

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not retry non-error rejections", async () => {
const fetchMock = vi.fn().mockRejectedValueOnce("fetch failed");
globalThis.fetch = fetchMock;

const rejection = await fetchWithRetry("https://example.com", { retryDelayMs: 0 }).then(
() => undefined,
(error: unknown) => error,
);

expect(rejection).toBe("fetch failed");
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("throws without ever attempting the fetch when retries is negative", async () => {
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;

const rejection = await fetchWithRetry("https://example.com", { retries: -1 }).then(
() => undefined,
(error: unknown) => error,
);

expect(rejection).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(0);
});
});
48 changes: 34 additions & 14 deletions src/_internals/fetch-with-retry/fetch-with-retry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
const RETRYABLE_ERROR_CODES = new Set([
import { isNullish } from "../is-nullish/is-nullish.ts";

export type FetchWithRetryOptions = RequestInit & {
/** How many times to retry a failed request (default: 2). */
retries?: number;
/** Delay between retries, in milliseconds (default: 300). */
retryDelayMs?: number;
};

const RETRYABLE_ERROR_CODES = [
"UND_ERR_SOCKET",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
Expand All @@ -8,18 +17,10 @@ const RETRYABLE_ERROR_CODES = new Set([
"EHOSTUNREACH",
"ENETUNREACH",
"ETIMEDOUT",
]);

export type FetchWithRetryOptions = RequestInit & {
retries?: number;
retryDelayMs?: number;
};

const wait = (ms: number): Promise<void> =>
ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms));
];

const getErrorCode = (error: unknown): string | undefined => {
if (!error || typeof error !== "object") return undefined;
if (isNullish(error) || typeof error !== "object") return undefined;

const code = "code" in error ? error.code : undefined;

Expand All @@ -29,17 +30,17 @@ const getErrorCode = (error: unknown): string | undefined => {

const cause = "cause" in error ? error.cause : undefined;

if (!cause || typeof cause !== "object") return undefined;
if (isNullish(cause) || typeof cause !== "object") return undefined;

const causeCode = "code" in cause ? cause.code : undefined;

return typeof causeCode === "string" ? causeCode : undefined;
};

export const isRetryableFetchError = (error: unknown): boolean => {
const isRetryableFetchError = (error: unknown): boolean => {
const code = getErrorCode(error);

if (code && RETRYABLE_ERROR_CODES.has(code)) {
if (code && RETRYABLE_ERROR_CODES.includes(code)) {
return true;
}

Expand All @@ -50,6 +51,25 @@ export const isRetryableFetchError = (error: unknown): boolean => {
return error.message.toLowerCase().includes("fetch failed");
};

const wait = (ms: number): Promise<void> =>
ms <= 0 ? Promise.resolve() : new Promise((resolve) => setTimeout(resolve, ms));

/**
* Performs a `fetch` retrying transient network failures with a linear backoff.
*
* Only failures accepted by `isRetryableFetchError` are retried; every other rejection is
* rethrown immediately. HTTP error statuses are not retried, since they resolve rather than
* reject.
*
* @param {string|URL|Request} input - The resource to fetch.
* @param {FetchWithRetryOptions} [options] - `fetch` init plus `retries` and `retryDelayMs`.
* @returns {Promise<Response>} The `fetch` response.
*
* @example
* ```typescript
* await fetchWithRetry("https://viacep.com.br/ws/01001000/json/", { retries: 1, retryDelayMs: 0 });
* ```
*/
export const fetchWithRetry = async (
input: string | URL | Request,
{ retries = 2, retryDelayMs = 250, ...init }: FetchWithRetryOptions = {},
Expand Down
23 changes: 23 additions & 0 deletions src/_internals/format/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,27 @@ describe("format", () => {
const result = format({ value: "123456", pattern: "" });
expect(result).toBe("");
});

it("should replace the positions marked with * by *", () => {
expect(format({ value: "12345678901", pattern: "***.000.000-**" })).toBe("***.456.789-**");
});

it("should leave separators untouched around the hidden positions", () => {
expect(format({ value: "12345678000195", pattern: "**.000.000/0000-**" })).toBe(
"**.345.678/0001-**",
);
});

it("should copy every position when the pattern has no *", () => {
expect(format({ value: "12345678901", pattern: "000.000.000-00" })).toBe("123.456.789-01");
});

it("should stop at the length of the value when the pattern has *", () => {
expect(format({ value: "123", pattern: "***.000.000-**" })).toBe("***");
expect(format({ value: "", pattern: "***.000.000-**" })).toBe("");
});

it("should count * as a slot when padding", () => {
expect(format({ value: "123", pattern: "***.000.000-**", pad: true })).toBe("***.000.001-**");
});
});
37 changes: 27 additions & 10 deletions src/_internals/format/format.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,51 @@
export type FormatParams = { value: string; pattern: string; pad?: boolean };
export type FormatParams = {
/** The raw value to format, already sanitized to the characters the pattern consumes. */
value: string;
/** The pattern to format against: `0` copies one input character, `*` hides one, anything else is a literal separator. */
pattern: string;
/** Whether to left pad the value with zeros up to the number of slots in the pattern (default: `false`). */
pad?: boolean;
};

/**
* Formats a given value according to a specified pattern.
*
* @param {Object} params - The parameters for formatting.
* @param {boolean} params.pad - Whether to pad the value with leading zeros.
* The pattern is read character by character:
* - `0` consumes one character of `value` and copies it;
* - `*` consumes one character of `value` and emits `*` in its place, hiding it;
* - anything else is a literal separator, emitted only while `value` still has characters left.
*
* `pad` counts both `0` and `*` as slots, so a value shorter than the pattern is left padded
* with zeros before it is consumed.
*
* @param {FormatParams} params - The parameters for formatting.
* @param {string} params.value - The value to be formatted.
* @param {string} params.pattern - The pattern to format the value against.
* @param {boolean} [params.pad] - Whether to pad the value with leading zeros.
* @returns {string} The formatted value.
*
* @example
* ```typescript
* format({ value: "123456", pattern: "000-000" }); // "123-456"
* format({ value: "123", pattern: "0000-000", pad: true }); // "0123-000"
* format({ value: "123", pattern: "0000-000", pad: true }); // "0000-123"
* format({ value: "12345678909", pattern: "***.000.000-**" }); // "***.456.789-**"
* ```
*/
export const format = ({ pad, value, pattern }: FormatParams): string => {
let formatted = "";
let digitIndex = 0;
let valueIndex = 0;

if (pad) {
const separatorsLength = pattern.replace(/0/g, "").length;
const separatorsLength = pattern.replace(/[0*]/g, "").length;
value = value.padStart(pattern.length - separatorsLength, "0");
}

for (const char of pattern) {
if (char === "0") {
if (digitIndex >= value.length) break;
formatted += value[digitIndex++];
} else if (digitIndex < value.length) {
if (char === "0" || char === "*") {
if (valueIndex >= value.length) break;
formatted += char === "*" ? "*" : value[valueIndex];
valueIndex++;
} else if (valueIndex < value.length) {
formatted += char;
}
}
Expand Down
Loading
Loading