diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index a4391b2b5..94bd3c80d 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -270,10 +270,14 @@ generateCep(); // '92500000' Busca o endereço de um CEP em vários provedores ao mesmo tempo e resolve com a primeira resposta bem-sucedida. O resultado é um `AddressInfo`: `cep`, `state`, `city`, `neighborhood` e `street`. -- **Opções** (`GetAddressInfoByCepOptions`): `providers` (`CepProvider[]`) lista os provedores a disputar (padrão `['viacep', 'brasilapi']`). `'widenet'` está descontinuado e fica fora da lista padrão. -- Aceita string ou número. Um número é preenchido com zeros à esquerda até 8 dígitos; um negativo ou fracionário é rejeitado com `GetAddressInfoByCepValidationError` antes de qualquer requisição. +- **Opções** (`GetAddressInfoByCepOptions`): + - `providers` (`CepProvider[]`) lista os provedores a disputar (padrão `['viacep', 'brasilapi']`). `'widenet'` está descontinuado e fica fora da lista padrão. + - `timeoutMs` (`number`) limita a busca inteira, tentativas incluídas (padrão: sem limite). Quando o tempo acaba, todas as requisições são abortadas e a chamada rejeita com `GetAddressInfoByCepServiceError`. + - `signal` (`AbortSignal`) cancela a busca; a chamada rejeita com `signal.reason`, como o `fetch`. +- Aceita string ou número. Uma string tem removido todo caractere que não é dígito (`'CEP 01310-100'` é `01310100`) e precisa sobrar com 8 dígitos. Um número é preenchido com zeros à esquerda até 8 dígitos, já que não carrega o zero inicial de um CEP de São Paulo, mas só a partir de `1000000` (`01000-000`, o menor CEP que os Correios atribuem). Um número menor, negativo ou fracionário é rejeitado com `GetAddressInfoByCepValidationError` antes de qualquer requisição. - Repete falhas transitórias de rede por provedor. -- Rejeita com `GetAddressInfoByCepValidationError` quando o CEP é inválido ou `providers` não nomeia nenhum provedor conhecido, com `GetAddressInfoByCepNotFoundError` quando todos os provedores falharam e pelo menos um informou que o CEP é desconhecido, e com `GetAddressInfoByCepServiceError` quando todos os provedores falharam por outro motivo. +- Rejeita com `GetAddressInfoByCepValidationError` quando o CEP é inválido, `providers` não nomeia nenhum provedor conhecido ou `timeoutMs` não é um número finito positivo, com `GetAddressInfoByCepNotFoundError` quando todos os provedores falharam e pelo menos um informou que o CEP é desconhecido, e com `GetAddressInfoByCepServiceError` quando todos os provedores falharam por outro motivo. +- A BrasilAPI responde 404 tanto para um CEP desconhecido quanto quando os serviços por trás dela estão fora do ar, então o 404 dela só conta como "CEP desconhecido" quando nenhum outro provedor deixou de responder. - Os três estendem `GetAddressInfoByCepError`, então um único `catch` cobre todos. ```javascript @@ -290,6 +294,9 @@ const addressFromProviders = await getAddressInfoByCep('01310-100', { // Usando número como entrada (será preenchido automaticamente com zeros à esquerda) const addressFromNumber = await getAddressInfoByCep(1310100); + +// Desistindo depois de 5 segundos +const addressWithinFiveSeconds = await getAddressInfoByCep('01310100', { timeoutMs: 5000 }); ``` ### getCepInfoByAddress @@ -969,6 +976,7 @@ Fonte: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legis Retorna o estado e a região a que um DDD brasileiro (código de área) pertence, dentre os 67 DDDs em uso no Plano Geral de Numeração da Anatel. Aceita string ou número inteiro não negativo. +- Uma string tem removido todo caractere que não é dígito, então `'(11)'`, `'0xx11'` e `'DDD 11'` são o DDD 11. - Retorna um `AreaCodeInfo`: `areaCode`, `stateCode`, `stateName`, `regionCode`, `regionName` e `stateCodes`. Retorna `null` quando o DDD não está em uso. - `stateCode` é o estado sede do DDD. Para os quatro DDDs que cruzam uma divisa (61, 42, 47 e 49) `stateCodes` lista também o outro estado, a sede primeiro. @@ -985,6 +993,7 @@ getAreaCodeInfo('61'); // { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', regionCode: 'CO', regionName: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null +getAreaCodeInfo('(0xx11)'); // o mesmo que '11' getAreaCodeInfo(-11); // null getAreaCodeInfo(1.1); // null ``` @@ -1387,6 +1396,7 @@ Fonte: [lista de participantes do STR](https://www.bcb.gov.br/content/estabilida Busca um banco brasileiro pelo seu código de compensação (COMPE), a partir da lista de participantes do STR do Banco Central do Brasil. Aceita `string` ou `number`. +- Uma string tem removido todo caractere que não é dígito antes de o código ser completado para 3 dígitos. - Retorna o `Bank` correspondente, ou `null` quando nenhum banco tem esse código. ```javascript @@ -1401,7 +1411,9 @@ Fonte: [lista de participantes do STR](https://www.bcb.gov.br/content/estabilida ### getBankByIspb -Busca um banco brasileiro pelo seu ISPB (Identificador do Sistema de Pagamentos Brasileiro), o código de 8 dígitos de todo participante do SPB. Aceita `string` ou `number`, com ou sem zeros à esquerda. +Busca um banco brasileiro pelo seu ISPB (Identificador do Sistema de Pagamentos Brasileiro), o código de 8 caracteres de todo participante do SPB. Aceita `string` ou `number`, com ou sem zeros à esquerda. + +- Desde a Resolução BCB nº 585/2026 o ISPB pode ter letras, então uma string de 8 letras e dígitos é buscada como está, em maiúsculas ou minúsculas. Todo caractere que não é letra nem dígito é ignorado (`'00.000.000'` é `'00000000'`), e uma letra nunca é descartada (`'0000000A'` não é `'00000000'`). - Retorna o `Bank` correspondente, ou `null` quando nenhum banco tem esse ISPB. A base só traz as instituições que também têm código COMPE. @@ -1678,7 +1690,7 @@ Fonte: [Correios, Busca Faixa de CEP](https://buscacepinter.correios.com.br/app/ Retorna o estado brasileiro cujo código IBGE de 2 dígitos (`cUF`, o Código da Unidade da Federação) corresponde ao valor informado. - É o código de UF do primeiro campo de uma chave de acesso de DF-e, a que `isValidNfeKey` cobre. -- Aceita string ou número inteiro não negativo. +- Aceita string ou número inteiro não negativo, com todo caractere que não é dígito removido da string (`'35/SP'` é `35`). - Retorna `null` quando o código não corresponde a nenhum estado. Exporta o tipo `State`. ```javascript @@ -1791,7 +1803,7 @@ Fonte: [IBGE Localidades](https://servicodados.ibge.gov.br/api/docs/localidades) Busca um município brasileiro pelo código IBGE de 7 dígitos. -- Aceita o código como string ou número inteiro não negativo. +- Aceita o código como string ou número inteiro não negativo, com todo caractere que não é dígito removido da string. - Retorna `{ code, name, stateCode }` (`Municipality`), ou `null` quando o código não tem 7 dígitos ou não corresponde a nenhum município. ```javascript diff --git a/docs/utilities.md b/docs/utilities.md index fdbe29ff2..98f2aacd0 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -270,10 +270,14 @@ generateCep(); // '92500000' Fetch the address of a CEP from several providers at once and resolve to the first successful answer. The result is an `AddressInfo`: `cep`, `state`, `city`, `neighborhood` and `street`. -- **Options** (`GetAddressInfoByCepOptions`): `providers` (`CepProvider[]`) lists the providers to race (default `['viacep', 'brasilapi']`). `'widenet'` is deprecated and left out of the default list. -- Accepts a string or a number. A number is left-padded with zeros to 8 digits; a negative or fractional one is rejected with `GetAddressInfoByCepValidationError` before any request is made. +- **Options** (`GetAddressInfoByCepOptions`): + - `providers` (`CepProvider[]`) lists the providers to race (default `['viacep', 'brasilapi']`). `'widenet'` is deprecated and left out of the default list. + - `timeoutMs` (`number`) bounds the whole lookup, retries included (default: no limit). When it runs out, every request is aborted and the call rejects with `GetAddressInfoByCepServiceError`. + - `signal` (`AbortSignal`) cancels the lookup; the call rejects with `signal.reason`, the same as `fetch`. +- Accepts a string or a number. A string has any non-digit characters stripped (`'CEP 01310-100'` is `01310100`) and has to leave 8 digits. A number is left-padded with zeros to 8 digits, since it cannot carry the leading zero of a São Paulo CEP, but only from `1000000` (`01000-000`, the lowest CEP the Correios assign) up. A smaller, negative or fractional number is rejected with `GetAddressInfoByCepValidationError` before any request is made. - Retries transient network failures per provider. -- Rejects with `GetAddressInfoByCepValidationError` when the CEP is invalid or `providers` names no known provider, with `GetAddressInfoByCepNotFoundError` when every provider failed and at least one reported the CEP as unknown, and with `GetAddressInfoByCepServiceError` when every provider failed for another reason. +- Rejects with `GetAddressInfoByCepValidationError` when the CEP is invalid, `providers` names no known provider or `timeoutMs` is not a positive finite number, with `GetAddressInfoByCepNotFoundError` when every provider failed and at least one reported the CEP as unknown, and with `GetAddressInfoByCepServiceError` when every provider failed for another reason. +- BrasilAPI answers 404 both for an unknown CEP and when the services behind it are down, so its 404 only counts as "unknown CEP" when no other provider failed to answer. - All three extend `GetAddressInfoByCepError`, so one `catch` covers them. ```javascript @@ -290,6 +294,9 @@ const addressFromProviders = await getAddressInfoByCep('01310-100', { // Using number input (will be padded automatically) const addressFromNumber = await getAddressInfoByCep(1310100); + +// Giving up after 5 seconds +const addressWithinFiveSeconds = await getAddressInfoByCep('01310100', { timeoutMs: 5000 }); ``` ### getCepInfoByAddress @@ -969,6 +976,7 @@ Source: [Resolução Anatel nº 749/2022](https://informacoes.anatel.gov.br/legi Get the state and region a Brazilian DDD (area code) belongs to, out of the 67 DDDs in use under the Anatel Plano Geral de Numeração. Accepts a string or a non-negative integer. +- A string has any non-digit characters stripped, so `'(11)'`, `'0xx11'` and `'DDD 11'` are the DDD 11. - Returns an `AreaCodeInfo`: `areaCode`, `stateCode`, `stateName`, `regionCode`, `regionName` and `stateCodes`. Returns `null` when the DDD is not in use. - `stateCode` is the state the DDD is seated in. For the four DDDs that straddle a border (61, 42, 47 and 49) `stateCodes` also lists the other state, the seat first. @@ -985,6 +993,7 @@ getAreaCodeInfo('61'); // { areaCode: 61, stateCode: 'DF', stateName: 'Distrito Federal', regionCode: 'CO', regionName: 'Centro-Oeste', stateCodes: ['DF', 'GO'] } getAreaCodeInfo('00'); // null +getAreaCodeInfo('(0xx11)'); // the same as '11' getAreaCodeInfo(-11); // null getAreaCodeInfo(1.1); // null ``` @@ -1387,6 +1396,7 @@ Source: [STR participants list](https://www.bcb.gov.br/content/estabilidadefinan Look a Brazilian bank up by its compensation code (COMPE), from the Banco Central do Brasil STR participants list. Accepts a `string` or a `number`. +- A string has any non-digit characters stripped before the code is padded to 3 digits. - Returns the matching `Bank`, or `null` when no bank has that code. ```javascript @@ -1401,7 +1411,9 @@ Source: [STR participants list](https://www.bcb.gov.br/content/estabilidadefinan ### getBankByIspb -Look a Brazilian bank up by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), the 8 digit code of every SPB participant. Accepts a `string` or a `number`, with or without leading zeros. +Look a Brazilian bank up by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), the 8 character code of every SPB participant. Accepts a `string` or a `number`, with or without leading zeros. + +- Since Resolução BCB nº 585/2026 an ISPB may hold letters, so a string of 8 letters and digits is looked up as it is, in upper or lower case. Any character that is neither a letter nor a digit is ignored (`'00.000.000'` is `'00000000'`), and a letter is never stripped (`'0000000A'` is not `'00000000'`). - Returns the matching `Bank`, or `null` when no bank has that ISPB. The base only carries institutions that also have a COMPE code. @@ -1678,7 +1690,7 @@ Source: [Correios, Busca Faixa de CEP](https://buscacepinter.correios.com.br/app Get the Brazilian state whose 2-digit IBGE code (`cUF`, the Código da Unidade da Federação) matches the given value. - This is the UF code in the first field of a DF-e access key (chave de acesso), the one `isValidNfeKey` covers. -- Accepts a string or a non-negative integer. +- Accepts a string or a non-negative integer, with any non-digit characters of a string stripped (`'35/SP'` is `35`). - Returns `null` when the code matches no state. Exports the `State` type. ```javascript @@ -1791,7 +1803,7 @@ Source: [IBGE Localidades](https://servicodados.ibge.gov.br/api/docs/localidades Look up a Brazilian municipality by its 7-digit IBGE code. -- Accepts the code as a string or a non-negative integer. +- Accepts the code as a string or a non-negative integer, with any non-digit characters of a string stripped. - Returns `{ code, name, stateCode }` (`Municipality`), or `null` when the code is not 7 digits long or matches no municipality. ```javascript diff --git a/src/_internals/read-lookup-digits/read-lookup-digits.test.ts b/src/_internals/read-lookup-digits/read-lookup-digits.test.ts new file mode 100644 index 000000000..0f70ec5a9 --- /dev/null +++ b/src/_internals/read-lookup-digits/read-lookup-digits.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "../test/runtime"; +import { readLookupDigits } from "./read-lookup-digits"; + +describe("readLookupDigits", () => { + test("should read a string of digits as it is", () => { + expect(readLookupDigits("3550308")).toBe("3550308"); + expect(readLookupDigits("001")).toBe("001"); + }); + + test("should drop whitespace and hyphens", () => { + expect(readLookupDigits(" 355-030-8 ")).toBe("3550308"); + expect(readLookupDigits("0-01")).toBe("001"); + expect(readLookupDigits("11\t")).toBe("11"); + }); + + test("should read a non-negative integer number", () => { + expect(readLookupDigits(0)).toBe("0"); + expect(readLookupDigits(3_550_308)).toBe("3550308"); + }); + + test("should strip any non-digit character of a string, as up to 2.4.0", () => { + expect(readLookupDigits("(0xx11)")).toBe("011"); + expect(readLookupDigits("DDD 11")).toBe("11"); + expect(readLookupDigits("R$ 35")).toBe("35"); + expect(readLookupDigits("35/SP")).toBe("35"); + }); + + test("should return null for a string with no digit", () => { + expect(readLookupDigits("DDD")).toBeNull(); + expect(readLookupDigits("")).toBeNull(); + }); + + test("should reject a string with no digit left", () => { + expect(readLookupDigits("")).toBeNull(); + expect(readLookupDigits(" - ")).toBeNull(); + }); + + test("should reject a negative, fractional or unsafe number and a non-code value", () => { + expect(readLookupDigits(-11)).toBeNull(); + expect(readLookupDigits(1.1)).toBeNull(); + expect(readLookupDigits(2 ** 53)).toBeNull(); + expect(readLookupDigits(null)).toBeNull(); + expect(readLookupDigits(["11"])).toBeNull(); + }); +}); diff --git a/src/_internals/read-lookup-digits/read-lookup-digits.ts b/src/_internals/read-lookup-digits/read-lookup-digits.ts new file mode 100644 index 000000000..148bee218 --- /dev/null +++ b/src/_internals/read-lookup-digits/read-lookup-digits.ts @@ -0,0 +1,32 @@ +import { isLookupCode } from "../is-lookup-code/is-lookup-code"; +import { sanitizeToDigits } from "../sanitize-to-digits/sanitize-to-digits"; + +/** + * Reads the digits of a lookup code, or `null` when the value is not a code at all. + * + * A number is read when `isLookupCode` accepts it, a non-negative safe integer, since a sign or + * a decimal point would otherwise be read as part of a code the caller never wrote. A string has + * every character that is not a digit stripped, as up to 2.4.0 (the documented contract of the + * lookups), so a masked or labelled code such as `"(0xx11)"`, `"35/SP"` or `"00.000.000"` is + * read by its digits. + * + * @param {unknown} value - The value to read. + * @returns {string|null} The digits of the code, or `null` when the value is not a number + * `isLookupCode` accepts or a string, or when no digit is left. + * + * @example + * ```typescript + * readLookupDigits(" 355-030-8 "); // "3550308" + * readLookupDigits(3550308); // "3550308" + * readLookupDigits("(0xx11)"); // "011" + * readLookupDigits(" - "); // null + * readLookupDigits(-11); // null + * ``` + */ +export const readLookupDigits = (value: unknown): string | null => { + if (!isLookupCode(value)) return null; + + const digits = sanitizeToDigits(value); + + return digits === "" ? null : digits; +}; diff --git a/src/get-address-info-by-cep/get-address-info-by-cep.test.ts b/src/get-address-info-by-cep/get-address-info-by-cep.test.ts index d75d7e115..d607513c0 100644 --- a/src/get-address-info-by-cep/get-address-info-by-cep.test.ts +++ b/src/get-address-info-by-cep/get-address-info-by-cep.test.ts @@ -216,6 +216,108 @@ describe("getAddressInfoByCep", () => { vi.restoreAllMocks(); }); + describe("cancellation", () => { + const hangUntilAborted = (): void => { + fetchMock.mockImplementation( + (_input: FetchInput, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted", { cause: init.signal?.reason })); + }); + }), + ); + }; + + const requestSignal = (index: number): AbortSignal | null | undefined => + (fetchMock.mock.calls[index]?.[1] as RequestInit | undefined)?.signal; + + it("should hand every request of every provider a signal", async () => { + await getAddressInfoByCep(VALID_CEP, { providers: ["viacep", "widenet", "brasilapi"] }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(requestSignal(0)).toBeInstanceOf(AbortSignal); + expect(requestSignal(1)).toBeInstanceOf(AbortSignal); + expect(requestSignal(2)).toBeInstanceOf(AbortSignal); + }); + + it("should reject with GetAddressInfoByCepServiceError once timeoutMs runs out", async () => { + hangUntilAborted(); + + await expect(getAddressInfoByCep(VALID_CEP, { timeoutMs: 10 })).rejects.toThrow( + GetAddressInfoByCepServiceError, + ); + }); + + it("should reject with the reason of options.signal when it aborts", async () => { + hangUntilAborted(); + const controller = new AbortController(); + const reason = new Error("cancelled by the caller"); + const lookup = getAddressInfoByCep(VALID_CEP, { signal: controller.signal }); + + controller.abort(reason); + + await expect(lookup).rejects.toThrow(reason); + }); + + it("should reject with the reason of an already aborted signal, without a request", async () => { + const reason = new Error("cancelled before the lookup"); + + await expect( + getAddressInfoByCep(VALID_CEP, { signal: AbortSignal.abort(reason) }), + ).rejects.toThrow(reason); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should resolve as before when the signal never aborts and the time limit is not reached", async () => { + const controller = new AbortController(); + const result = await getAddressInfoByCep(VALID_CEP, { + signal: controller.signal, + timeoutMs: 60_000, + }); + + expectDefaultAddress(result); + }); + + it("should stop listening to options.signal once the lookup settles", async () => { + const controller = new AbortController(); + + await getAddressInfoByCep(VALID_CEP, { + providers: ["viacep"], + signal: controller.signal, + }); + controller.abort(new Error("cancelled after the lookup")); + + expect(requestSignal(0)?.aborted).toBe(false); + }); + + it("should clear the time limit once the lookup settles", async () => { + await getAddressInfoByCep(VALID_CEP, { providers: ["viacep"], timeoutMs: 10 }); + await new Promise((resolve) => { + setTimeout(resolve, 30); + }); + + expect(requestSignal(0)?.aborted).toBe(false); + }); + + it("should set no time limit without timeoutMs", async () => { + fetchMock.mockImplementation( + (_input: FetchInput, init?: RequestInit) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + resolve(createJsonResponse(viacepPayload)); + }, 20); + + init?.signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(new Error("aborted", { cause: init.signal?.reason })); + }); + }), + ); + + expectDefaultAddress(await getAddressInfoByCep(VALID_CEP, { providers: ["viacep"] })); + }); + }); + describe("validation", () => { it("should throw GetAddressInfoByCepValidationError for invalid CEP format", async () => { await expect(getAddressInfoByCep("12345")).rejects.toThrow( @@ -271,6 +373,64 @@ describe("getAddressInfoByCep", () => { expect(result).toBeDefined(); expect(result.cep).toBe(VALID_CEP); }); + + it("should strip any non-digit character of a string CEP, as up to 2.4.0", async () => { + expectDefaultAddress(await getAddressInfoByCep("CEP 01310-100")); + expectDefaultAddress(await getAddressInfoByCep("01310/100")); + }); + + it("should reject a string whose digits are not the 8 of a CEP, without a request", async () => { + await expect(getAddressInfoByCep("CEP 0131-100")).rejects.toThrow( + GetAddressInfoByCepValidationError, + ); + await expect(getAddressInfoByCep("CEP")).rejects.toThrow( + GetAddressInfoByCepValidationError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should accept a CEP masked with dots and whitespace, the separators isValidCep accepts", async () => { + const result = await getAddressInfoByCep(" 01.310-100 "); + + expect(result.cep).toBe(VALID_CEP); + expect(requestUrl(fetchMock.mock.calls[0]?.[0] as FetchInput)).toContain(VALID_CEP); + }); + + it("should reject a number below 1000000, which no CEP pads to, without a request", async () => { + await expect(getAddressInfoByCep(123)).rejects.toThrow(GetAddressInfoByCepValidationError); + await expect(getAddressInfoByCep(123)).rejects.toThrow("CEP inválido"); + await expect(getAddressInfoByCep(999_999)).rejects.toThrow( + GetAddressInfoByCepValidationError, + ); + await expect(getAddressInfoByCep(0)).rejects.toThrow(GetAddressInfoByCepValidationError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should pad 1000000, the lowest CEP the Correios assign, to 01000-000", async () => { + await getAddressInfoByCep(1_000_000, { providers: ["viacep"] }); + + expect(requestUrl(fetchMock.mock.calls[0]?.[0] as FetchInput)).toBe( + "https://viacep.com.br/ws/01000000/json/", + ); + }); + + it("should reject a number past 8 digits", async () => { + await expect(getAddressInfoByCep(100_000_000)).rejects.toThrow( + GetAddressInfoByCepValidationError, + ); + }); + + it("should reject a timeoutMs that is not a positive finite number, without a request", async () => { + await Promise.all( + [0, -1, Number.NaN, Number.POSITIVE_INFINITY, "1000"].map((timeoutMs) => + expect( + // @ts-expect-error: intentionally invalid input + getAddressInfoByCep(VALID_CEP, { timeoutMs }), + ).rejects.toThrow("Tempo limite inválido"), + ), + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); describe("provider selection", () => { @@ -552,6 +712,39 @@ describe("getAddressInfoByCep", () => { ); }); + it("should throw GetAddressInfoByCepServiceError when BrasilAPI answers 404 but another provider failed to answer", async () => { + setupFetchMock(fetchMock, { + brasilapi: createJsonResponse({ errors: [{ message: "CEP não encontrado" }] }, 404), + viacep: new Error("Network error"), + }); + + await expect(getAddressInfoByCep(VALID_CEP)).rejects.toThrow( + GetAddressInfoByCepServiceError, + ); + }); + + it("should throw GetAddressInfoByCepNotFoundError when BrasilAPI answers 404 and ViaCEP does not know the CEP either", async () => { + setupFetchMock(fetchMock, { + brasilapi: createJsonResponse({ errors: [{ message: "CEP não encontrado" }] }, 404), + viacep: createJsonResponse({ erro: true }), + }); + + await expect(getAddressInfoByCep(VALID_CEP)).rejects.toThrow( + GetAddressInfoByCepNotFoundError, + ); + }); + + it("should throw GetAddressInfoByCepServiceError when BrasilAPI answers 404 and ViaCEP answers an error status", async () => { + setupFetchMock(fetchMock, { + brasilapi: createJsonResponse({ errors: [{ message: "CEP não encontrado" }] }, 404), + viacep: createJsonResponse({}, 500), + }); + + await expect(getAddressInfoByCep(VALID_CEP)).rejects.toThrow( + GetAddressInfoByCepServiceError, + ); + }); + it("should throw GetAddressInfoByCepServiceError when BrasilAPI answers a non-404 error status", async () => { setupFetchMock(fetchMock, { brasilapi: createJsonResponse({}, 500), diff --git a/src/get-address-info-by-cep/get-address-info-by-cep.ts b/src/get-address-info-by-cep/get-address-info-by-cep.ts index f1fa8e787..8dffba912 100644 --- a/src/get-address-info-by-cep/get-address-info-by-cep.ts +++ b/src/get-address-info-by-cep/get-address-info-by-cep.ts @@ -1,7 +1,9 @@ +import { CEP_LENGTH } from "../_internals/constants/cep"; import { fetchWithRetry } from "../_internals/fetch-with-retry/fetch-with-retry"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; import { isValidCep } from "../is-valid-cep/is-valid-cep"; +import { parseCep } from "../parse-cep/parse-cep"; /** Base class of every error `getAddressInfoByCep` rejects with. */ export class GetAddressInfoByCepError extends Error { @@ -60,6 +62,18 @@ export type GetAddressInfoByCepOptions = { * requested explicitly). */ providers?: CepProvider[]; + /** + * Cancels the lookup: every request in flight is aborted and the promise rejects with + * `signal.reason`, the same as `fetch` does. A signal that is already aborted rejects before + * any request is made. + */ + signal?: AbortSignal; + /** + * How long, in milliseconds, the whole lookup may take, retries included, before every request + * in flight is aborted and the promise rejects with `GetAddressInfoByCepServiceError` (default: + * no limit). Must be a positive finite number. + */ + timeoutMs?: number; }; type ProviderPayload = Record; @@ -70,10 +84,31 @@ type ProviderPayload = Record; * not-found signal is an HTTP status and the only one that needs it mapped before `response.ok` * turns it into a service failure. * + * BrasilAPI answers the same 404 when the services behind it could not be reached, and rewrites + * their connection errors into "not found" messages, so the status alone cannot tell a CEP that + * does not exist from an outage. It is therefore read as `AmbiguousNotFoundError`, which only + * turns into `GetAddressInfoByCepNotFoundError` when no other provider failed to answer. + * * @see Based on: https://brasilapi.com.br/docs#tag/CEP + * @see Based on: https://github.com/BrasilAPI/BrasilAPI/blob/main/pages/api/cep/v1/%5Bcep%5D.js + * Every `service_error` of the services behind it, a connection failure included, is answered + * with `NotFoundError`, the 404. */ const BRASIL_API_NOT_FOUND_STATUS = 404; +/** + * The lowest CEP the Correios assign: the São Paulo range starts at `01000-000`, and no range + * covers `00000-000` to `00999-999`. A number cannot carry the leading zero of a São Paulo CEP, + * so a number is left padded to 8 digits, but one below this is not a CEP with its leading zeros + * lost: `123` is not the CEP `00000-123`. + * + * @see Official: https://buscacepinter.correios.com.br/app/faixa_cep_uf_localidade/index.php + */ +const LOWEST_CEP = 1_000_000; + +/** A not-found answer that an outage could also have produced; see `BRASIL_API_NOT_FOUND_STATUS`. */ +class AmbiguousNotFoundError extends GetAddressInfoByCepNotFoundError {} + const asString = (value: unknown): string => (typeof value === "string" ? value : ""); const readPayload = async (response: Response): Promise => { @@ -82,8 +117,8 @@ const readPayload = async (response: Response): Promise => { return Object.assign({}, data); }; -const fetchViaCep = async (cep: string): Promise => { - const response = await fetchWithRetry(`https://viacep.com.br/ws/${cep}/json/`); +const fetchViaCep = async (cep: string, signal: AbortSignal): Promise => { + const response = await fetchWithRetry(`https://viacep.com.br/ws/${cep}/json/`, { signal }); if (!response.ok) { // Stryker disable next-line StringLiteral: only `instanceof GetAddressInfoByCepNotFoundError` @@ -109,9 +144,10 @@ const fetchViaCep = async (cep: string): Promise => { }; }; -const fetchWidenet = async (cep: string): Promise => { +const fetchWidenet = async (cep: string, signal: AbortSignal): Promise => { const response = await fetchWithRetry( `https://apps.widenet.com.br/busca-cep/api/cep/${cep}.json`, + { signal }, ); if (!response.ok) { @@ -138,13 +174,13 @@ const fetchWidenet = async (cep: string): Promise => { }; }; -const fetchBrasilApi = async (cep: string): Promise => { - const response = await fetchWithRetry(`https://brasilapi.com.br/api/cep/v1/${cep}`); +const fetchBrasilApi = async (cep: string, signal: AbortSignal): Promise => { + const response = await fetchWithRetry(`https://brasilapi.com.br/api/cep/v1/${cep}`, { signal }); if (response.status === BRASIL_API_NOT_FOUND_STATUS) { - // Stryker disable next-line StringLiteral: only `instanceof GetAddressInfoByCepNotFoundError` - // is checked when aggregating provider failures below, so this message is never observable. - throw new GetAddressInfoByCepNotFoundError("CEP não encontrado"); + // Stryker disable next-line StringLiteral: only the class of a provider failure is checked + // when aggregating provider failures below, so this message is never observable. + throw new AmbiguousNotFoundError("CEP não encontrado"); } if (!response.ok) { @@ -171,10 +207,141 @@ const fetchBrasilApi = async (cep: string): Promise => { }; }; -const providerMap: Record Promise> = { - viacep: fetchViaCep, - widenet: fetchWidenet, - brasilapi: fetchBrasilApi, +const providerMap: Record Promise> = + { + viacep: fetchViaCep, + widenet: fetchWidenet, + brasilapi: fetchBrasilApi, + }; + +const DEFAULT_PROVIDERS: readonly CepProvider[] = ["viacep", "brasilapi"]; + +/** + * Reads the CEP `getAddressInfoByCep` looks up, as 8 bare digits, under the rules its JSDoc + * gives: a string has any non-digit characters stripped, as up to 2.4.0, and has to leave the 8 + * digits of a CEP; a number from `LOWEST_CEP` up is left padded to 8 digits. + * + * @param {unknown} cep - The CEP given. + * @returns {string} The 8 digits of the CEP. + * @throws {GetAddressInfoByCepValidationError} When the value is not a CEP. + */ +const readCep = (cep: unknown): string => { + if (!isLookupCode(cep) || (typeof cep === "number" && cep < LOWEST_CEP)) { + throw new GetAddressInfoByCepValidationError("CEP inválido"); + } + + const cepValue = + typeof cep === "number" ? String(cep).padStart(CEP_LENGTH, "0") : sanitizeToDigits(cep); + + if (!isValidCep(cepValue)) { + throw new GetAddressInfoByCepValidationError("CEP inválido"); + } + + return parseCep(cepValue); +}; + +/** + * Reads `options.providers`, dropping the names of unknown providers. + * + * @param {CepProvider[]} [providers] - The `options.providers` given. + * @returns {CepProvider[]} The providers to race. + * @throws {GetAddressInfoByCepValidationError} When no known provider is left. + */ +const readProviders = (providers: GetAddressInfoByCepOptions["providers"]): CepProvider[] => { + if (providers === undefined) return [...DEFAULT_PROVIDERS]; + + // An empty array also filters down to no provider, which reports the same validation error, so + // there is no dedicated check for it here. + const known = Array.isArray(providers) + ? providers.filter((provider) => Object.hasOwn(providerMap, provider)) + : []; + + if (known.length === 0) { + throw new GetAddressInfoByCepValidationError("Nenhum provedor válido especificado"); + } + + return known; +}; + +/** + * Reads `options.timeoutMs`. + * + * `Number.isFinite` never coerces its argument, so it also turns down a value that is not a + * number at all, such as `"1000"`. + * + * @param {number} [timeoutMs] - The `options.timeoutMs` given. + * @returns {number|undefined} The time limit, or `undefined` for none. + * @throws {GetAddressInfoByCepValidationError} When it is given and is not a positive finite number. + */ +const readTimeout = (timeoutMs: number | undefined): number | undefined => { + if (timeoutMs === undefined) return undefined; + + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new GetAddressInfoByCepValidationError("Tempo limite inválido"); + } + + return timeoutMs; +}; + +/** + * Aborts `controller` with the reason of `signal` when `signal` aborts. + * + * @param {AbortSignal} signal - The caller's signal. + * @param {AbortController} controller - The controller whose signal every request gets. + * @returns {() => void} Stops forwarding, so a lookup that settled leaves no listener behind. + */ +const forwardAbort = (signal: AbortSignal, controller: AbortController): (() => void) => { + const abort = (): void => { + controller.abort(signal.reason); + }; + + signal.addEventListener("abort", abort); + + return () => { + signal.removeEventListener("abort", abort); + }; +}; + +/** + * Races the providers for one CEP and turns their failures into the error `getAddressInfoByCep` + * rejects with. A reliable "not found" wins over a service failure; BrasilAPI's 404 only wins when + * no provider failed to answer (see `BRASIL_API_NOT_FOUND_STATUS`). + * + * @param {CepProvider[]} providers - The providers to race. + * @param {string} cep - The 8 digits of the CEP. + * @param {AbortSignal} signal - Aborts every request. + * @returns {Promise} The first address a provider answers with. + */ +const raceProviders = async ( + providers: CepProvider[], + cep: string, + signal: AbortSignal, +): Promise => { + let notFound = false; + let serviceFailed = false; + + const providerPromises = providers.map(async (provider) => { + try { + return await providerMap[provider](cep, signal); + } catch (error) { + if (!(error instanceof GetAddressInfoByCepNotFoundError)) serviceFailed = true; + else if (!(error instanceof AmbiguousNotFoundError)) notFound = true; + throw error; + } + }); + + try { + return await Promise.any(providerPromises); + } catch { + // Every provider failed, so when none failed to answer, every one of them said "not found". + if (notFound || !serviceFailed) { + throw new GetAddressInfoByCepNotFoundError("CEP não encontrado em nenhum serviço"); + } + + throw new GetAddressInfoByCepServiceError( + "Todos os serviços estão fora de serviço ou indisponíveis", + ); + } }; /** @@ -186,22 +353,41 @@ const providerMap: Record Promise> = * back the moment its own failure lands, and therefore the moment an all-failed rejection can * surface. * - * A number is only read as a CEP when it is a non-negative safe integer: a sign or a decimal - * point would otherwise be dropped and another CEP looked up, so such a number is rejected - * before any request is made. + * A string CEP has any non-digit characters stripped, as up to 2.4.0, so `"CEP 01310-100"` is + * looked up as `01310-100`, and what is left has to be the 8 digits of a CEP. A number is only read as a CEP when it + * is a non-negative safe integer: a sign or a decimal point would otherwise be dropped and + * another CEP looked up, so such a number is rejected before any request is made. A number + * cannot carry the leading zero of a São Paulo CEP, so it is left padded to 8 digits, but only + * from `1000000` (`01000-000`, the lowest CEP the Correios assign) up: a smaller number is + * rejected instead of being looked up as a CEP starting with `00`, as it was up to 2.4.0. + * + * A "not found" answer only wins over a service failure when it is reliable: ViaCEP's and + * Widenet's are, but BrasilAPI answers 404 both for an unknown CEP and when the services behind + * it are down. Its 404 is therefore reported as `GetAddressInfoByCepNotFoundError` only when no + * other provider failed to answer; next to a network failure it is reported as + * `GetAddressInfoByCepServiceError`. Up to 2.4.0 it always won, so an outage could be reported + * as an unknown CEP. + * + * No request has a time limit of its own. Pass `options.timeoutMs` to bound the whole lookup, or + * `options.signal` to cancel it. * * @param {string|number} cep - The CEP (Brazilian postal code) to search for. Can be a string or number. * @param {GetAddressInfoByCepOptions} options - Optional configuration for the function. * @param {CepProvider[]} options.providers - List of providers to use. Defaults to `["viacep", "brasilapi"]` * if not specified (the deprecated `"widenet"` provider is excluded from the default list, but can still * be requested explicitly). + * @param {AbortSignal} options.signal - Cancels the lookup, rejecting with `signal.reason`. + * @param {number} options.timeoutMs - Time limit of the whole lookup, in milliseconds. * @returns {Promise} A promise that resolves to the address information. - * @throws {GetAddressInfoByCepValidationError} If the CEP format is invalid, or if - * `options.providers` is given and names no known provider: an empty array, an array of unknown - * names, and a value that is not an array at all (`null` included) all reject this way rather - * than with a raw `TypeError`. + * @throws {GetAddressInfoByCepValidationError} If the CEP format is invalid, if + * `options.providers` is given and names no known provider (an empty array, an array of unknown + * names, and a value that is not an array at all, `null` included, all reject this way rather + * than with a raw `TypeError`), or if `options.timeoutMs` is given and is not a positive finite + * number. * @throws {GetAddressInfoByCepNotFoundError} If the CEP is not found in any of the services. - * @throws {GetAddressInfoByCepServiceError} If all services are unavailable. + * @throws {GetAddressInfoByCepServiceError} If all services are unavailable, or + * `options.timeoutMs` ran out first. + * @throws {unknown} `options.signal.reason`, when the signal aborts the lookup. * * @example * ```typescript @@ -218,6 +404,12 @@ const providerMap: Record Promise> = * * // A negative or fractional number is rejected * await getAddressInfoByCep(-1310100); // throws GetAddressInfoByCepValidationError + * + * // So is a number below 1000000, which no CEP pads to + * await getAddressInfoByCep(123); // throws GetAddressInfoByCepValidationError + * + * // Giving up after 5 seconds + * await getAddressInfoByCep("01310100", { timeoutMs: 5000 }); * ``` * * @see Official: https://www.correios.com.br/enviar/precisa-de-ajuda/tudo-sobre-cep @@ -230,51 +422,30 @@ export const getAddressInfoByCep = async ( cep: string | number, options?: GetAddressInfoByCepOptions, ): Promise => { - let cepString = sanitizeToDigits(cep); - - if (typeof cep === "number") { - // `padStart` is a no-op when `cepString` is already 8 characters or longer, so there is no - // need to check its length here first. - cepString = cepString.padStart(8, "0"); - } - - if (!isLookupCode(cep) || !isValidCep(cepString)) { - throw new GetAddressInfoByCepValidationError("CEP inválido"); - } - - let providersToUse: CepProvider[]; - if (options?.providers === undefined) { - providersToUse = ["viacep", "brasilapi"]; - } else if (Array.isArray(options.providers)) { - // An empty `options.providers` array also filters down to an empty `providersToUse` below, - // which already reports the same validation error, so there is no dedicated check for it here. - providersToUse = options.providers.filter((provider) => Object.hasOwn(providerMap, provider)); - if (providersToUse.length === 0) { - throw new GetAddressInfoByCepValidationError("Nenhum provedor válido especificado"); - } - } else { - throw new GetAddressInfoByCepValidationError("Nenhum provedor válido especificado"); - } - - let notFound = false; - const providerPromises = providersToUse.map(async (provider) => { - try { - return await providerMap[provider](cepString); - } catch (error) { - if (error instanceof GetAddressInfoByCepNotFoundError) notFound = true; - throw error; - } - }); + const cepString = readCep(cep); + const providersToUse = readProviders(options?.providers); + const timeoutMs = readTimeout(options?.timeoutMs); + const signal = options?.signal; + + signal?.throwIfAborted(); + + const controller = new AbortController(); + const stopForwarding = signal === undefined ? undefined : forwardAbort(signal, controller); + const timer = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + controller.abort(); + }, timeoutMs); try { - return await Promise.any(providerPromises); - } catch { - if (notFound) { - throw new GetAddressInfoByCepNotFoundError("CEP não encontrado em nenhum serviço"); - } - - throw new GetAddressInfoByCepServiceError( - "Todos os serviços estão fora de serviço ou indisponíveis", - ); + return await raceProviders(providersToUse, cepString, controller.signal); + } catch (error) { + signal?.throwIfAborted(); + + throw error; + } finally { + clearTimeout(timer); + stopForwarding?.(); } }; diff --git a/src/get-area-code-info/get-area-code-info.test.ts b/src/get-area-code-info/get-area-code-info.test.ts index cc18dfeb5..0773009d9 100644 --- a/src/get-area-code-info/get-area-code-info.test.ts +++ b/src/get-area-code-info/get-area-code-info.test.ts @@ -94,12 +94,29 @@ describe("getAreaCodeInfo", () => { } }); - it("should ignore non-digit characters around the DDD", () => { + it("should ignore whitespace around the DDD", () => { expect(getAreaCodeInfo(" 11 ")?.stateCode).toBe("SP"); }); it("should ignore a parentheses mask around the DDD", () => { expect(getAreaCodeInfo("(11)")?.stateCode).toBe("SP"); + expect(getAreaCodeInfo(" (11) ")?.stateCode).toBe("SP"); + }); + + it("should strip any non-digit character of a string, as up to 2.4.0", () => { + for (const value of ["(11)", "0xx11", "(0xx11)", "DDD 11", "11-", "1.1"]) { + expect(getAreaCodeInfo(value)?.areaCode).toBe(11); + } + }); + + it("should return null for a string with no digit", () => { + expect(getAreaCodeInfo("DDD")).toBeNull(); + expect(getAreaCodeInfo("()")).toBeNull(); + }); + + it("should return null for a string with no digits, even inside parentheses", () => { + expect(getAreaCodeInfo("()")).toBeNull(); + expect(getAreaCodeInfo(" - ")).toBeNull(); }); it("should return null for a DDD that does not exist, such as 00", () => { diff --git a/src/get-area-code-info/get-area-code-info.ts b/src/get-area-code-info/get-area-code-info.ts index d5bc5030e..bc4c3f2f9 100644 --- a/src/get-area-code-info/get-area-code-info.ts +++ b/src/get-area-code-info/get-area-code-info.ts @@ -1,7 +1,6 @@ import { AREA_CODE_SECONDARY_STATES, AREA_CODE_STATES } from "../_internals/constants/area-codes"; import { DATA, type State, type StateCode, type StateName } from "../_internals/constants/states"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { readLookupDigits } from "../_internals/read-lookup-digits/read-lookup-digits"; export type { State, StateCode, StateName } from "../_internals/constants/states"; @@ -38,7 +37,8 @@ export type AreaCodeInfo = { * the seat does hold every municipality but the one named. * * A `areaCode` given as a number must be a non-negative integer: a sign and a decimal point - * are not digits, so `-11` and `1.1` are rejected instead of being read as `11`. + * are not digits, so `-11` and `1.1` are rejected instead of being read as `11`. A string has + * any non-digit characters stripped, so `"(11)"`, `"0xx11"` and `"DDD 11"` are the DDD 11. * * @param {string|number} areaCode - The DDD to look up. Accepts a string or a non-negative * integer number, with any non-digit characters stripped before matching. @@ -74,13 +74,15 @@ export type AreaCodeInfo = { * // { areaCode: 61, stateCode: "DF", stateName: "Distrito Federal", regionCode: "CO", regionName: "Centro-Oeste", stateCodes: ["DF", "GO"] } * * getAreaCodeInfo("00"); // null + * getAreaCodeInfo("(0xx11)"); // the same as "11" * getAreaCodeInfo(-11); // null * ``` */ export const getAreaCodeInfo = (areaCode: string | number): AreaCodeInfo | null => { - if (!isLookupCode(areaCode)) return null; + const digits = readLookupDigits(areaCode); - const digits = sanitizeToDigits(areaCode); + // Stryker disable next-line ConditionalExpression: without this guard a null reads as the DDD 0, which no state has, so the lookup below returns null all the same; the guard only spares it. + if (digits === null) return null; const numericAreaCode = Number(digits); diff --git a/src/get-bank-by-code/get-bank-by-code.test.ts b/src/get-bank-by-code/get-bank-by-code.test.ts index b9bfa9c37..ea9f7c420 100644 --- a/src/get-bank-by-code/get-bank-by-code.test.ts +++ b/src/get-bank-by-code/get-bank-by-code.test.ts @@ -5,6 +5,11 @@ import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime import { getBankByCode } from "./get-bank-by-code"; describe("getBankByCode", () => { + test("should strip any non-digit character of a string, as up to 2.4.0", () => { + expect(getBankByCode("341/")?.code).toBe("341"); + expect(getBankByCode("0x1")?.code).toBe("001"); + }); + describe("should return null for a negative or fractional number", () => { test("whose digits would otherwise match a bank", () => { expect(getBankByCode(-1)).toBeNull(); @@ -69,8 +74,14 @@ describe("getBankByCode", () => { expect(getBankByCode("00001")).toBeNull(); }); - test("when the code sanitizes to an empty string", () => { + test("when the code has no digits", () => { + expect(getBankByCode("abc")).toBeNull(); + expect(getBankByCode(" - ")).toBeNull(); + }); + + test("when the code has no digit", () => { expect(getBankByCode("abc")).toBeNull(); + expect(getBankByCode(" - ")).toBeNull(); }); test("when the code is an empty string", () => { diff --git a/src/get-bank-by-code/get-bank-by-code.ts b/src/get-bank-by-code/get-bank-by-code.ts index 199680db2..59b749069 100644 --- a/src/get-bank-by-code/get-bank-by-code.ts +++ b/src/get-bank-by-code/get-bank-by-code.ts @@ -1,6 +1,5 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { readLookupDigits } from "../_internals/read-lookup-digits/read-lookup-digits"; export type { Bank } from "../_internals/constants/banks"; @@ -10,6 +9,9 @@ const CODE_LENGTH = 3; * Looks up a Brazilian bank by its compensation code (COMPE), published by Banco Central do * Brasil in the STR (Sistema de Transferência de Reservas) participants list. * + * Any non-digit characters of a string are stripped and the code is left padded with zeros, so + * `1`, `"1"` and `"0-01"` are all `"001"`. + * * @param {string|number} code - The bank's COMPE code, with or without leading zeros. * @returns {Bank|null} A fresh copy of the matching bank, or `null` when no bank has that code. * @@ -25,12 +27,10 @@ const CODE_LENGTH = 3; * Fallback source used by the dataset generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ export const getBankByCode = (code: string | number): Bank | null => { - if (!isLookupCode(code)) return null; - - const digits = sanitizeToDigits(code); + const digits = readLookupDigits(code); - // Stryker disable next-line ConditionalExpression,LogicalOperator: no bank has code "000" and padStart never shortens an oversized code, so bypassing this guard can never change which bank is found. - if (digits.length === 0 || digits.length > CODE_LENGTH) return null; + // Stryker disable next-line ConditionalExpression: padStart never shortens an oversized code, and no bank has a code longer than 3 digits, so bypassing this half of the guard can never change which bank is found. + if (digits === null || digits.length > CODE_LENGTH) return null; const normalizedCode = digits.padStart(CODE_LENGTH, "0"); diff --git a/src/get-bank-by-ispb/get-bank-by-ispb.test.ts b/src/get-bank-by-ispb/get-bank-by-ispb.test.ts index 4d321180c..7d74b2be5 100644 --- a/src/get-bank-by-ispb/get-bank-by-ispb.test.ts +++ b/src/get-bank-by-ispb/get-bank-by-ispb.test.ts @@ -5,6 +5,12 @@ import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime import { getBankByIspb } from "./get-bank-by-ispb"; describe("getBankByIspb", () => { + test("should drop every character that is neither a letter nor a digit, as up to 2.4.0", () => { + expect(getBankByIspb("00.000.000")?.code).toBe("001"); + expect(getBankByIspb("0000/0000")?.code).toBe("001"); + expect(getBankByIspb(" 60.701.190 ")?.code).toBe("341"); + }); + describe("should return null for a negative or fractional number", () => { test("whose digits would otherwise match a bank", () => { expect(getBankByIspb(-208)).toBeNull(); @@ -73,10 +79,25 @@ describe("getBankByIspb", () => { expect(getBankByIspb("0000000000")).toBeNull(); }); - test("when the ispb sanitizes to an empty string", () => { + test("when the ispb has letters and fewer than 8 characters", () => { expect(getBankByIspb("abc")).toBeNull(); }); + test("when the ispb has a letter, instead of reading its digits as another ISPB", () => { + expect(getBankByIspb("0000000A")).toBeNull(); + expect(getBankByIspb("A0000000")).toBeNull(); + expect(getBankByIspb("a0000000")).toBeNull(); + }); + + test("when the ispb, stripped of anything but letters and digits, is no ISPB", () => { + expect(getBankByIspb("1e0")).toBeNull(); + expect(getBankByIspb("ISPB 00000000")).toBeNull(); + }); + + test("when the ispb is only separators", () => { + expect(getBankByIspb(" - ")).toBeNull(); + }); + test("when the ispb is an empty string", () => { expect(getBankByIspb("")).toBeNull(); }); diff --git a/src/get-bank-by-ispb/get-bank-by-ispb.ts b/src/get-bank-by-ispb/get-bank-by-ispb.ts index 2fc3d7c80..d7e51b81e 100644 --- a/src/get-bank-by-ispb/get-bank-by-ispb.ts +++ b/src/get-bank-by-ispb/get-bank-by-ispb.ts @@ -1,11 +1,13 @@ import { BANKS, type Bank } from "../_internals/constants/banks"; import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; export type { Bank } from "../_internals/constants/banks"; const ISPB_LENGTH = 8; +/** Every character that is neither a letter nor a digit, dropped from an ISPB. */ +const NON_ALPHANUMERIC_REGEX = /[^\da-z]/gi; + /** * Looks up a Brazilian bank by its ISPB (Identificador do Sistema de Pagamentos Brasileiro), * the 8 digit code that identifies every participant of the SPB, published by Banco Central do @@ -13,6 +15,15 @@ const ISPB_LENGTH = 8; * participant has an ISPB, but this dataset only carries the institutions that also have a * COMPE code, so an ISPB whose institution has no COMPE code of its own returns `null`. * + * The ISPB is read the way `isValidIban` reads the one inside an IBAN: 8 characters that may be + * letters as well as digits, since Resolução BCB nº 585/2026 art. 2º III made it "oito + * caracteres alfanuméricos", upper or lower case. Every character that is neither a letter nor + * a digit is dropped, as up to 2.4.0 (so the ISPB may be printed with the CNPJ root mask, + * `"00.000.000"`), and a shorter value is left padded with zeros, so `0` is the ISPB `00000000`. + * A letter is part of the ISPB, so it is never stripped: up to 2.4.0 `"0000000A"` and + * `"A0000000"` were read as `00000000`, the ISPB of Banco do Brasil, and a value longer than 8 + * characters finds no ISPB. + * * @param {string|number} value - The bank's ISPB, with or without leading zeros. * @returns {Bank|null} A fresh copy of the matching bank, or `null` when no bank has that ISPB. * @@ -22,23 +33,26 @@ const ISPB_LENGTH = 8; * getBankByIspb(0); // { code: "001", ispb: "00000000", name: "Banco do Brasil S.A." } * getBankByIspb("60701190"); // { code: "341", ispb: "60701190", name: "ITAÚ UNIBANCO S.A." } * getBankByIspb("99999999"); // null + * getBankByIspb("0000000A"); // null (no bank has that ISPB, and it is not read as 00000000) * ``` * * @see Official: https://www.bcb.gov.br/content/estabilidadefinanceira/str1/ParticipantesSTR.csv + * @see Official: https://www.bcb.gov.br/estabilidadefinanceira/exibenormativo?tipo=Resolu%C3%A7%C3%A3o%20BCB&numero=585 + * Resolução BCB nº 585, de 24/08/2026 (DOU 25/08/2026), art. 2º III, the alphanumeric ISPB. * @see Based on: https://brasilapi.com.br/api/banks/v1 * Fallback source used by the dataset generator (`scripts/banks.ts`) when the Bacen CSV request fails. */ export const getBankByIspb = (value: string | number): Bank | null => { if (!isLookupCode(value)) return null; - const digits = sanitizeToDigits(value); - - // Stryker disable next-line ConditionalExpression: every ISPB in BANKS is exactly 8 digits, so an oversized value can never match one, whether or not this half of the guard runs. - if (digits.length === 0 || digits.length > ISPB_LENGTH) return null; + // Stryker disable next-line MethodExpression: no ISPB in BANKS has a letter yet, so a lower case letter misses the table whether or not it is folded to upper case. + const code = String(value).replaceAll(NON_ALPHANUMERIC_REGEX, "").toUpperCase(); - const normalizedIspb = digits.padStart(ISPB_LENGTH, "0"); + // An empty value would pad to 00000000, the ISPB of Banco do Brasil. + if (code === "") return null; - const bank = BANKS.find((candidate) => candidate.ispb === normalizedIspb); + const ispb = code.padStart(ISPB_LENGTH, "0"); + const bank = BANKS.find((candidate) => candidate.ispb === ispb); return bank ? { ...bank } : null; }; diff --git a/src/get-municipality-by-code/get-municipality-by-code.test.ts b/src/get-municipality-by-code/get-municipality-by-code.test.ts index a60f7a2ce..75047fa4c 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.test.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.test.ts @@ -85,7 +85,13 @@ describe("getMunicipalityByCode", () => { expect(getMunicipalityByCode(Number.POSITIVE_INFINITY)).toBeNull(); }); - it("should ignore non-digit characters before validating the length", () => { + it("should strip any non-digit character of a string, as up to 2.4.0", () => { + expect(getMunicipalityByCode("3550308 SP")?.name).toBe("São Paulo"); + expect(getMunicipalityByCode("355.030.8")?.name).toBe("São Paulo"); + expect(getMunicipalityByCode("3550308/")?.name).toBe("São Paulo"); + }); + + it("should ignore hyphens before validating the length", () => { expect(getMunicipalityByCode("355-030-8")).toEqual({ code: "3550308", name: "São Paulo", diff --git a/src/get-municipality-by-code/get-municipality-by-code.ts b/src/get-municipality-by-code/get-municipality-by-code.ts index b7af95a73..fc8c9d046 100644 --- a/src/get-municipality-by-code/get-municipality-by-code.ts +++ b/src/get-municipality-by-code/get-municipality-by-code.ts @@ -1,7 +1,6 @@ import { DATA as CITIES_DATA, type Municipality } from "../_internals/constants/municipalities"; import { STATE_CODES } from "../_internals/constants/state-codes"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { readLookupDigits } from "../_internals/read-lookup-digits/read-lookup-digits"; export type { Municipality } from "../_internals/constants/municipalities"; @@ -9,7 +8,8 @@ export type { Municipality } from "../_internals/constants/municipalities"; * Looks up a Brazilian municipality by its 7 digit IBGE code, published by the IBGE. * * A `code` given as a number must be a non-negative integer: a sign and a decimal point are - * not digits, so `-3550308` and `355030.8` are rejected instead of being read as `3550308`. + * not digits, so `-3550308` and `355030.8` are rejected instead of being read as `3550308`. A + * string has any non-digit characters stripped, so `"3550308 SP"` is the code `3550308`. * * @param {string|number} code - The 7 digit IBGE municipality code, as a string or a number. * @returns {Municipality|null} A fresh copy of the matching municipality, or `null` when @@ -28,9 +28,10 @@ export type { Municipality } from "../_internals/constants/municipalities"; * codes as the bundled table. */ export const getMunicipalityByCode = (code: string | number): Municipality | null => { - if (!isLookupCode(code)) return null; + const digits = readLookupDigits(code); - const digits = sanitizeToDigits(code); + // Stryker disable next-line ConditionalExpression: without this guard a null matches no municipality code, all strings, so the loop below returns null all the same; the guard also narrows the type of `digits`. + if (digits === null) return null; // Every real municipality code is exactly 7 digits, so a `digits` of the wrong length simply // finds no match in the loop below; there is no need to pre-validate its length here first. diff --git a/src/get-municipality/get-municipality.test.ts b/src/get-municipality/get-municipality.test.ts index f5afb47de..67396f457 100644 --- a/src/get-municipality/get-municipality.test.ts +++ b/src/get-municipality/get-municipality.test.ts @@ -107,6 +107,14 @@ describe("getMunicipality", () => { await expect(getMunicipality({ code: "3550308?x=1" })).resolves.toBeNull(); }); + it("should strip any non-digit character of a code, as up to 2.4.0", async () => { + await expect(getMunicipality({ code: "3550308 SP" })).resolves.toEqual(["São Paulo", "SP"]); + }); + + it("should ignore whitespace and hyphens in a code", async () => { + await expect(getMunicipality({ code: " 355-030-8 " })).resolves.toEqual(["São Paulo", "SP"]); + }); + it("should return null for a code that is neither a string nor a number", async () => { // @ts-expect-error: intentionally invalid input await expect(getMunicipality({ code: null })).resolves.toBeNull(); diff --git a/src/get-municipality/get-municipality.ts b/src/get-municipality/get-municipality.ts index 64fcc9323..e917877f2 100644 --- a/src/get-municipality/get-municipality.ts +++ b/src/get-municipality/get-municipality.ts @@ -1,9 +1,8 @@ import { DATA as CITIES_DATA } from "../_internals/constants/municipalities"; import { type StateCode } from "../_internals/constants/states"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; import { isNullish } from "../_internals/is-nullish/is-nullish"; import { normalizeMunicipalityName } from "../_internals/normalize-municipality-name/normalize-municipality-name"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { readLookupDigits } from "../_internals/read-lookup-digits/read-lookup-digits"; /** The `getMunicipality` query by IBGE municipality code. */ export type GetMunicipalityByCodeParams = { @@ -48,7 +47,10 @@ export type GetMunicipalityOptions = GetMunicipalityParams; let codeIndex: Map | undefined; const getMunicipalityByCode = (code: string | number): [string, string] | null => { - if (!isLookupCode(code)) return null; + const digits = readLookupDigits(code); + + // Stryker disable next-line ConditionalExpression: without this guard a null misses the index, whose keys are all strings, so the lookup below returns null all the same; the guard also narrows the type of `digits`. + if (digits === null) return null; // Stryker disable next-line ConditionalExpression: this guard only memoizes; CITIES_DATA is a module level constant that is never written to, so rebuilding the index on every call produces the very same entries, and each lookup already returns a fresh copy of the pair, leaving the repeated work unobservable. if (!codeIndex) { @@ -61,10 +63,9 @@ const getMunicipalityByCode = (code: string | number): [string, string] | null = } } - // `Map#get` never throws and simply misses for a key of the wrong shape (a malformed, too - // short or too long code), so only the sign and the decimal point of a numeric `code`, which - // `sanitizeToDigits` would silently drop, have to be pre-validated above. - const entry = codeIndex.get(sanitizeToDigits(code)); + // `Map#get` never throws and simply misses for a key of the wrong length, so only the + // characters `readLookupDigits` turns down have to be checked above. + const entry = codeIndex.get(digits); return entry ? [...entry] : null; }; @@ -98,7 +99,8 @@ const getMunicipalityCodeByName = ({ * Looks a Brazilian municipality up by its IBGE code in the offline IBGE "localidades" dataset. * * A `code` given as a number must be a non-negative integer: a sign and a decimal point are not - * digits, so `-3550308` and `355030.8` are rejected instead of being read as `3550308`. + * digits, so `-3550308` and `355030.8` are rejected instead of being read as `3550308`. A string + * has any non-digit characters stripped, the same as `getMunicipalityByCode`. * * @deprecated Use `getMunicipalityByCode` instead, which is synchronous and offline; matching a * municipality by name is up to the application, over `getMunicipalities`. diff --git a/src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts b/src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts index 38ccafc03..fdb7fe5ac 100644 --- a/src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts +++ b/src/get-state-by-ibge-code/get-state-by-ibge-code.test.ts @@ -77,8 +77,16 @@ describe("getStateByIbgeCode", () => { expect(getStateByIbgeCode()).toBeNull(); }); - it("should ignore non-digit characters around the code", () => { + it("should ignore whitespace and hyphens around the code", () => { expect(getStateByIbgeCode(" 35 ")?.code).toBe("SP"); + expect(getStateByIbgeCode("-35-")?.code).toBe("SP"); + }); + + it("should strip any non-digit character of a string, as up to 2.4.0", () => { + expect(getStateByIbgeCode("x11")?.code).toBe("RO"); + expect(getStateByIbgeCode("35/SP")?.code).toBe("SP"); + expect(getStateByIbgeCode("R$ 35")?.code).toBe("SP"); + expect(getStateByIbgeCode("3.5")?.code).toBe("SP"); }); describe("properties", () => { @@ -86,10 +94,10 @@ describe("getStateByIbgeCode", () => { expectNeverThrows(getStateByIbgeCode, anyGarbage); }); - test("should resolve every known ibgeCode regardless of surrounding non-digit noise", () => { + test("should resolve every known ibgeCode regardless of surrounding whitespace and hyphens", () => { const knownIbgeCodeArbitrary = fc.constantFrom(...STATES.map((state) => state.ibgeCode)); const noiseArbitrary = fc - .array(fc.constantFrom(" ", "-", ".", "/", "R", "$", "a", "Z")) + .array(fc.constantFrom(" ", "-", "\t")) .map((characters) => characters.join("")); fc.assert( @@ -103,6 +111,18 @@ describe("getStateByIbgeCode", () => { ), ); }); + + test("should find every known ibgeCode next to a letter, stripping it", () => { + const knownIbgeCodeArbitrary = fc.constantFrom(...STATES.map((state) => state.ibgeCode)); + const letterArbitrary = fc.constantFrom("a", "Z", "e", "x"); + + fc.assert( + fc.property(knownIbgeCodeArbitrary, letterArbitrary, (ibgeCode, letter) => { + expect(getStateByIbgeCode(`${letter}${ibgeCode}`)?.ibgeCode).toBe(ibgeCode); + expect(getStateByIbgeCode(`${ibgeCode}${letter}`)?.ibgeCode).toBe(ibgeCode); + }), + ); + }); }); }); diff --git a/src/get-state-by-ibge-code/get-state-by-ibge-code.ts b/src/get-state-by-ibge-code/get-state-by-ibge-code.ts index 266b41d9e..21870e412 100644 --- a/src/get-state-by-ibge-code/get-state-by-ibge-code.ts +++ b/src/get-state-by-ibge-code/get-state-by-ibge-code.ts @@ -1,6 +1,5 @@ import { DATA, type State } from "../_internals/constants/states"; -import { isLookupCode } from "../_internals/is-lookup-code/is-lookup-code"; -import { sanitizeToDigits } from "../_internals/sanitize-to-digits/sanitize-to-digits"; +import { readLookupDigits } from "../_internals/read-lookup-digits/read-lookup-digits"; export type { State } from "../_internals/constants/states"; @@ -36,9 +35,10 @@ export type { State } from "../_internals/constants/states"; * ``` */ export const getStateByIbgeCode = (code: string | number): State | null => { - if (!isLookupCode(code)) return null; + const digits = readLookupDigits(code); - const digits = sanitizeToDigits(code); + // Stryker disable next-line ConditionalExpression: without this guard a null reads as the code 0, which no state has, so the lookup below returns null all the same; the guard only spares it. + if (digits === null) return null; const numericCode = Number(digits);