summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md78
-rw-r--r--package.json2
-rw-r--r--src/index.js146
-rw-r--r--test/browser.test.js117
-rw-r--r--test/fixture.html5
-rw-r--r--test/server.js27
6 files changed, 351 insertions, 24 deletions
diff --git a/README.md b/README.md
index 242859a..0489a3d 100644
--- a/README.md
+++ b/README.md
@@ -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 },
);
}