diff options
| -rw-r--r-- | README.md | 78 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | src/index.js | 146 | ||||
| -rw-r--r-- | test/browser.test.js | 117 | ||||
| -rw-r--r-- | test/fixture.html | 5 | ||||
| -rw-r--r-- | test/server.js | 27 |
6 files changed, 351 insertions, 24 deletions
@@ -6,7 +6,7 @@ widget fills the street, neighbourhood, city and state fields you point it at. Official Correios data (DNE). One `<script>` tag, no build step, no framework, no dependencies. -**3.3 KB minified and gzipped** (7.7 KB minified, 7859 bytes). +**3.7 KB minified and gzipped** (8.8 KB minified, 9042 bytes). ```html <script src="cepis-autofill.js"></script> @@ -134,15 +134,16 @@ to render nothing and drive your own UI from `onStatus`. Every failure has its own message, in Portuguese, and reaches both the status region and `onError`: -| `error.code` | message | worth retrying? | -| ------------------ | ------------------------------------------------------------------------- | --------------- | -| `invalid` | CEP inválido. Digite os 8 dígitos. | no | -| `not_found` | CEP não encontrado. Confira o número ou preencha o endereço manualmente. | no | -| `rate_limited` | Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo. | later | -| `server` | O serviço de CEP está indisponível. Preencha o endereço manualmente. | later | -| `network` | Não foi possível consultar o CEP. Verifique sua conexão e tente de novo. | later | -| `timeout` | A consulta do CEP demorou demais. Tente de novo ou preencha manualmente. | later | -| `invalid_response` | Resposta inesperada do serviço de CEP. Preencha o endereço manualmente. | probably not | +| `error.code` | message | worth retrying? | +| ------------------ | ----------------------------------------------------------------------------------------- | --------------- | +| `invalid` | CEP inválido. Digite os 8 dígitos. | no | +| `not_found` | CEP não encontrado. Confira o número ou preencha o endereço manualmente. | no | +| `not_found` + city | CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de 25845-001 a 25849-899. | no | +| `rate_limited` | Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo. | later | +| `server` | O serviço de CEP está indisponível. Preencha o endereço manualmente. | later | +| `network` | Não foi possível consultar o CEP. Verifique sua conexão e tente de novo. | later | +| `timeout` | A consulta do CEP demorou demais. Tente de novo ou preencha manualmente. | later | +| `invalid_response` | Resposta inesperada do serviço de CEP. Preencha o endereço manualmente. | probably not | Override any of them with `messages`: @@ -156,6 +157,59 @@ CepisAutofill.attach({ `rate_limited` carries `retryAfterMs` and `server` carries `status`. +### When the CEP does not exist, but the city does + +A CEP that fails is rarely a random number. It is usually someone who knows +the city and guessed the last digits — most often the `-000` that 957 +municipalities never received. Areal/RJ, for one, answers from `25845-001` +up, and never at `25845-000`. + +So when the API can tell which municipality the number would have belonged +to, the message says it: + +> CEP não encontrado. Esse número seria de Areal/RJ, que tem CEPs de +> 25845-001 a 25849-899. + +**Nothing is filled in.** This is still a `not_found`: the address does not +exist, so the widget writes no field — not the city, not the state, nothing. +Filling the city from a CEP that does not exist would quietly produce a wrong +address, which is worse than an empty form. Only the wording of the message +changes. + +The range is not a list of valid CEPs either. `cep_ini` and `cep_fim` are the +lowest and the highest CEP that really exist in that municipality, and what +lies between them is sparse — Areal has 118 CEPs spread over some 4,900 +numbers. Show it as a hint of where to look; never suggest a CEP from inside +it. + +Where the API says nothing about a municipality, the plain +`CEP não encontrado…` message stands, exactly as before. The same goes for a +reply this widget cannot make sense of. + +Two extra entries in `messages` carry the wording, with `{localidade}`, +`{uf}`, `{cep_ini}` and `{cep_fim}` filled in: + +| key | used when | +| ---------------------- | ------------------------------------------- | +| `not_found_city_range` | the municipality and its first and last CEP | +| `not_found_city` | the municipality, but no usable range | + +```js +CepisAutofill.attach({ + cep: "#cep", + localidade: "#cidade", + messages: { + not_found_city_range: + "Esse CEP não existe. Em {localidade}/{uf} eles vão de {cep_ini} a {cep_fim}.", + }, +}); +``` + +`onError` gets the whole thing as `error.faixa` — `localidade`, `uf`, and +whichever of `estado`, `regiao`, `ibge`, `cep_ini`, `cep_fim` and `ceps` the +API sent — for a richer UI of your own. It is absent, never null, when there +is no municipality to name. + ### Looking a CEP up yourself `CepisAutofill.lookup` is the request layer on its own — same cache, same @@ -169,7 +223,9 @@ address.uf; // "SP" It resolves to the API's own JSON: Portuguese field names, empty strings rather than nulls, and `geo` present only for the roughly 57% of CEPs that -have a coordinate. +have a coordinate. It rejects with a `CepisAutofillError`, whose `faixa` — +when the CEP does not exist but its municipality does — is +[described above](#when-the-cep-does-not-exist-but-the-city-does). ## Mapping your platform's fields diff --git a/package.json b/package.json index 798ab4b..a242375 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cepis-autofill", - "version": "0.1.0", + "version": "0.2.0", "description": "Drop-in address autofill widget for Brazilian checkouts, on top of the CEP API at cep.is", "keywords": [ "cep", diff --git a/src/index.js b/src/index.js index d2f531e..77f0dcc 100644 --- a/src/index.js +++ b/src/index.js @@ -10,7 +10,7 @@ */ /** Version of this widget. */ -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0"; const DEFAULT_BASE_URL = "https://cep.is"; const DEFAULT_DEBOUNCE_MS = 250; @@ -18,6 +18,8 @@ const DEFAULT_TIMEOUT_MS = 8000; const NOT_DIGIT = /\D+/g; const DIACRITIC = /[\u0300-\u036f]/g; +const EIGHT_DIGITS = /^\d{8}$/; +const PLACEHOLDER = /\{(localidade|uf|cep_ini|cep_fim)\}/g; const ADDRESS_FIELDS = [ "cep", @@ -38,12 +40,21 @@ const ADDRESS_FIELDS = [ /** * The message shown for each state, in Portuguese. Override any of them * through the `messages` option of {@link attach}. + * + * `not_found_city` and `not_found_city_range` are not states of their own: + * they are the wording used for the `not_found` state when the API tells us + * which municipality the CEP would have belonged to. `{localidade}`, `{uf}`, + * `{cep_ini}` and `{cep_fim}` are filled in; the two CEPs are masked. Whenever + * that information is missing, plain `not_found` is used instead. */ export const MESSAGES = { idle: "", loading: "Buscando endereço…", found: "Endereço encontrado.", not_found: "CEP não encontrado. Confira o número ou preencha o endereço manualmente.", + not_found_city: "CEP não encontrado. Esse número seria de {localidade}/{uf}.", + not_found_city_range: + "CEP não encontrado. Esse número seria de {localidade}/{uf}, que tem CEPs de {cep_ini} a {cep_fim}.", invalid: "CEP inválido. Digite os 8 dígitos.", rate_limited: "Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo.", server: "O serviço de CEP está indisponível. Preencha o endereço manualmente.", @@ -67,12 +78,15 @@ export const MESSAGES = { * | `timeout` | the request outlived `timeoutMs` | later | * | `invalid_response` | a success status with an unreadable body | probably not | * | `aborted` | superseded by a newer keystroke; never surfaced to the shopper | no | + * + * A `not_found` may carry `faixa`: the municipality the CEP would have + * belonged to. See {@link CepisAutofillError#faixa}. */ export class CepisAutofillError extends Error { /** * @param {string} code One of the codes in the table above. * @param {string} message Human message, in Portuguese. - * @param {{status?: number, retryAfterMs?: number, cep?: string, cause?: unknown}} [details] + * @param {{status?: number, retryAfterMs?: number, cep?: string, faixa?: Record<string, any>, cause?: unknown}} [details] */ constructor(code, message, details = {}) { super(message, details.cause === undefined ? undefined : { cause: details.cause }); @@ -81,6 +95,24 @@ export class CepisAutofillError extends Error { this.status = details.status; this.retryAfterMs = details.retryAfterMs; this.cep = details.cep; + /** + * On a `not_found`, the municipality whose CEP range brackets the number + * asked for — `{localidade, uf}` plus whichever of `estado`, `regiao`, + * `ibge`, `cep_ini`, `cep_fim` and `ceps` the API sent. Absent whenever + * the CEP falls outside every municipality, or the API said nothing + * usable. + * + * **This is context for a failure, not an address.** The CEP asked for + * does not exist, so neither does the address it would have had: the + * widget never writes any of this into a form field, and neither should + * you. `cep_ini` and `cep_fim` are the lowest and highest CEP that really + * exist in that municipality, not a contiguous run — Areal/RJ has 118 of + * them spread over some 4,900 numbers — so they are worth showing as a + * hint of where the right CEP lies, never as a list to pick from. + * + * @type {Record<string, any>|undefined} + */ + this.faixa = details.faixa; } } @@ -118,6 +150,87 @@ function retryAfterMs(header) { return Number.isNaN(target) ? undefined : Math.max(0, target - Date.now()); } +function trimmed(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function maskCep(digits) { + return `${digits.slice(0, 5)}-${digits.slice(5)}`; +} + +/** + * Read the `faixa` a 404 may carry: the municipality the CEP would have + * belonged to, had it existed. + * + * Every field is optional and none is trusted. A body that is not JSON, a + * `faixa` that is not an object, a missing `localidade` or `uf` — all of it + * yields `undefined`, and the caller falls back to the plain "não encontrado". + * This never throws. + * + * @param {string} body The raw 404 body. + * @returns {Record<string, any>|undefined} + */ +function parseFaixa(body) { + let data; + try { + data = JSON.parse(body); + } catch { + return undefined; + } + const raw = data && typeof data === "object" ? data.faixa : undefined; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return undefined; + } + + const localidade = trimmed(raw.localidade); + const uf = trimmed(raw.uf); + if (localidade === "" || uf === "") { + return undefined; + } + + const faixa = { localidade, uf }; + for (const field of ["estado", "regiao", "ibge"]) { + const value = trimmed(raw[field]); + if (value !== "") { + faixa[field] = value; + } + } + const ini = trimmed(raw.cep_ini); + const fim = trimmed(raw.cep_fim); + if (EIGHT_DIGITS.test(ini) && EIGHT_DIGITS.test(fim)) { + faixa.cep_ini = ini; + faixa.cep_fim = fim; + } + if (typeof raw.ceps === "number" && Number.isFinite(raw.ceps)) { + faixa.ceps = raw.ceps; + } + return faixa; +} + +/** + * Word the `not_found` state, naming the municipality when we know it. + * + * @param {Record<string, any>|undefined} faixa From {@link parseFaixa}. + * @param {Record<string, string>} messages The message table in force. + * @returns {string} + */ +function notFoundMessage(faixa, messages) { + if (!faixa) { + return messages.not_found; + } + const template = faixa.cep_ini ? messages.not_found_city_range : messages.not_found_city; + if (typeof template !== "string" || template === "") { + return messages.not_found; + } + return template.replace(PLACEHOLDER, (_, key) => { + const value = faixa[key]; + if (typeof value !== "string") { + return ""; + } + return key === "cep_ini" || key === "cep_fim" ? maskCep(value) : value; + }); +} + function apiMessage(body, fallback) { try { const data = JSON.parse(body); @@ -139,6 +252,9 @@ function apiMessage(body, fallback) { * empty strings rather than nulls, and `geo` present only for the CEPs that * have a coordinate. * + * A `not_found` may carry a `faixa` naming the municipality the CEP would have + * belonged to — context for the failure, never an address to fill in. + * * @param {string} cep Masked or unmasked. * @param {{baseUrl?: string, timeoutMs?: number, signal?: AbortSignal}} [options] * @returns {Promise<Record<string, any>>} The address. @@ -154,7 +270,7 @@ export async function lookup(cep, options = {}) { if (hit.address) { return hit.address; } - throw new CepisAutofillError(hit.code, hit.message, { cep: digits }); + throw new CepisAutofillError(hit.code, hit.message, { cep: digits, faixa: hit.faixa }); } const controller = new AbortController(); @@ -198,8 +314,12 @@ export async function lookup(cep, options = {}) { } if (response.status === 404) { - cache.set(key, { code: "not_found", message: MESSAGES.not_found }); - throw new CepisAutofillError("not_found", MESSAGES.not_found, { cep: digits }); + // A 404 may name the municipality the CEP would have belonged to. It stays + // an error: the address does not exist, so nothing here is ever filled in. + const faixa = parseFaixa(body); + const message = notFoundMessage(faixa, MESSAGES); + cache.set(key, { code: "not_found", message, faixa }); + throw new CepisAutofillError("not_found", message, { cep: digits, faixa }); } if (response.status === 400) { throw new CepisAutofillError("invalid", apiMessage(body, MESSAGES.invalid), { cep: digits }); @@ -353,7 +473,9 @@ let regionCount = 0; * @param {(address: Record<string, any>, context: {cep: string}) => void} [options.onResult] * Called after a successful lookup, once the fields are filled. * @param {(error: CepisAutofillError, context: {cep: string}) => void} [options.onError] - * Called for every failure the shopper sees. Never called for `aborted`. + * Called for every failure the shopper sees. Never called for `aborted`. A + * `not_found` may carry `error.faixa`; see {@link CepisAutofillError#faixa} + * for why it must not be filled into the form. * @param {(state: string, message: string) => void} [options.onStatus] Called * on every state change: `idle`, `loading`, `found`, or an error code. * @returns {{detach: () => void, lookup: (cep?: string) => Promise<Record<string, any>|null>, element: () => Element|null}} @@ -425,8 +547,8 @@ export function attach(options) { return node; } - function announce(kind) { - const message = messages[kind] ?? ""; + function announce(kind, text) { + const message = text ?? messages[kind] ?? ""; const node = region(); if (node) { node.setAttribute( @@ -452,6 +574,10 @@ export function attach(options) { } } + // Only ever called with an address the API confirmed exists. A `faixa` from a + // 404 must never reach it: naming the city of a CEP that does not exist is a + // hint for the shopper, and silently writing it into the form would be a + // wrong address. function fill(address) { for (const field of ADDRESS_FIELDS) { if (field === "cep" || !options[field]) { @@ -514,7 +640,9 @@ export function attach(options) { return null; } const code = error instanceof CepisAutofillError ? error.code : "network"; - announce(code); + // No field is touched on this path — a `not_found` only changes the + // wording of the message, never the form. + announce(code, code === "not_found" ? notFoundMessage(error.faixa, messages) : undefined); if (options.onError) { options.onError(error, { cep: digits }); } diff --git a/test/browser.test.js b/test/browser.test.js index 634df0d..afbfb2c 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -68,6 +68,22 @@ function events() { return page.evaluate(() => window.__events); } +function lastError() { + return page.evaluate(() => window.__error); +} + +/** Every field the fixture maps, so a test can prove none of them was written. */ +async function filledFields() { + const filled = {}; + for (const selector of ["#rua", "#bairro", "#cidade", "#uf", "#numero"]) { + const current = await value(selector); + if (current !== "") { + filled[selector] = current; + } + } + return filled; +} + async function waitForRequests(count, timeout = 4000) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { @@ -209,8 +225,10 @@ describe("errors", () => { document.querySelector(".cepis-autofill-status").textContent.includes("não encontrado"), ); - expect(await statusText()).toContain("CEP não encontrado"); - expect(await value("#rua")).toBe(""); + expect(await statusText()).toBe( + "CEP não encontrado. Confira o número ou preencha o endereço manualmente.", + ); + expect(await filledFields()).toEqual({}); expect(await events()).toContain("error:not_found"); }); @@ -256,6 +274,101 @@ describe("errors", () => { }); }); +describe("the municipality behind a CEP that does not exist", () => { + async function lookFor(cep) { + await open(); + await typeCep(cep); + await page.waitForFunction(() => + document.querySelector(".cepis-autofill-status").textContent.includes("não encontrado"), + ); + } + + test("the message names the city and where its CEPs start and end", async () => { + await lookFor("25845000"); + + expect(await statusText()).toBe( + "CEP não encontrado. Esse número seria de Areal/RJ, " + + "que tem CEPs de 25845-001 a 25849-899.", + ); + expect(await events()).toContain("error:not_found"); + }); + + test("naming the city fills nothing — it is a failure, not an address", async () => { + await lookFor("25845000"); + + expect(await filledFields()).toEqual({}); + expect(await events()).not.toContain("result:25845000"); + expect(await page.locator("#cep").getAttribute("aria-invalid")).toBe("true"); + }); + + test("the faixa reaches onError, whole", async () => { + await lookFor("25845000"); + + const error = await lastError(); + expect(error.code).toBe("not_found"); + expect(error.faixa).toEqual({ + localidade: "Areal", + uf: "RJ", + estado: "Rio de Janeiro", + regiao: "Sudeste", + ibge: "3300225", + cep_ini: "25845001", + cep_fim: "25849899", + ceps: 118, + }); + }); + + test("a CEP in no municipality keeps the plain message", async () => { + await lookFor("99999999"); + + expect(await statusText()).toBe( + "CEP não encontrado. Confira o número ou preencha o endereço manualmente.", + ); + expect(await filledFields()).toEqual({}); + expect((await lastError()).faixa).toBeUndefined(); + }); + + test("a malformed faixa falls back to the plain message", async () => { + await lookFor("25845999"); + + expect(await statusText()).toBe( + "CEP não encontrado. Confira o número ou preencha o endereço manualmente.", + ); + expect(await filledFields()).toEqual({}); + expect((await lastError()).faixa).toBeUndefined(); + }); + + test("the wording is overridable like every other message", async () => { + await open({ + messages: { not_found_city_range: "Nada aqui. Tente um CEP de {localidade} ({uf})." }, + }); + await typeCep("25845000"); + await page.waitForFunction(() => + document.querySelector(".cepis-autofill-status").textContent.includes("Nada aqui"), + ); + + expect(await statusText()).toBe("Nada aqui. Tente um CEP de Areal (RJ)."); + expect(await filledFields()).toEqual({}); + }); + + test("lookup() hands the faixa over without touching the form", async () => { + await open(); + const thrown = await page.evaluate(async (baseUrl) => { + try { + await window.CepisAutofill.lookup("25845-000", { baseUrl }); + return null; + } catch (error) { + return { code: error.code, message: error.message, localidade: error.faixa.localidade }; + } + }, api.url); + + expect(thrown.code).toBe("not_found"); + expect(thrown.message).toContain("Areal/RJ"); + expect(thrown.localidade).toBe("Areal"); + expect(await filledFields()).toEqual({}); + }); +}); + describe("what the shopper typed", () => { test("overwrite: false leaves a filled field alone", async () => { await open({ overwrite: false }); diff --git a/test/fixture.html b/test/fixture.html index 6d6535d..cef959f 100644 --- a/test/fixture.html +++ b/test/fixture.html @@ -48,6 +48,11 @@ }, onError: function (error) { window.__events.push("error:" + error.code); + window.__error = { + code: error.code, + message: error.message, + faixa: error.faixa, + }; }, onStatus: function (state) { window.__events.push("status:" + state); diff --git a/test/server.js b/test/server.js index 720f4a0..db31c63 100644 --- a/test/server.js +++ b/test/server.js @@ -40,6 +40,26 @@ const ADDRESSES = { }, }; +/** + * The `faixa` a 404 carries when the CEP falls inside a municipality that + * simply never received that number. `25845000` is the real Areal/RJ shape — + * the `-000` that municipality never got. `25845999` is deliberately broken, + * so the tests can prove a malformed `faixa` degrades to the plain message. + */ +const FAIXAS = { + 25845000: { + localidade: "Areal", + uf: "RJ", + estado: "Rio de Janeiro", + regiao: "Sudeste", + ibge: "3300225", + cep_ini: "25845001", + cep_fim: "25849899", + ceps: 118, + }, + 25845999: { uf: "RJ", cep_ini: 25845001 }, +}; + const CORS = { "Access-Control-Allow-Origin": "*", "Content-Type": "application/json; charset=utf-8", @@ -105,8 +125,13 @@ export function startApi() { } const address = ADDRESSES[path]; if (!address) { + const faixa = FAIXAS[path]; return new Response( - JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }), + JSON.stringify({ + erro: "cep_nao_encontrado", + mensagem: "CEP não existe na base do DNE", + ...(faixa ? { faixa } : {}), + }), { status: 404, headers: CORS }, ); } |