import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Address, CEPNotFound, CepisError, Client, Faixa, InvalidCEP, InvalidResponse, NetworkError, RateLimited, ServerError, normalizeCep, parseCsv, } from "../src/index.js"; import { startServer, type FakeServer } from "./server.js"; const SE_ADDRESS: Record = { cep: "01001000", logradouro: "Praça da Sé", complemento: "lado ímpar", unidade: "", bairro: "Sé", localidade: "São Paulo", uf: "SP", estado: "São Paulo", regiao: "Sudeste", ibge: "3550308", gia: "1004", ddd: "11", siafi: "7107", }; const AREAL_FAIXA: Record = { localidade: "Areal", uf: "RJ", estado: "Rio de Janeiro", regiao: "Sudeste", ibge: "3300225", cep_ini: "25845001", cep_fim: "25849899", ceps: 118, }; const CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"; function csv(rows: readonly string[]): string { return `${CSV_HEADER}\n${rows.join("\n")}\n`; } function batchPath(ceps: readonly string[]): string { return `/csv?${new URLSearchParams({ ceps: ceps.join(",") }).toString()}`; } let server: FakeServer; let client: Client; beforeEach(() => { server = startServer(); client = new Client({ baseUrl: server.url, timeoutMs: 5_000, backoffBaseMs: 1, backoffMaxMs: 10, }); }); afterEach(() => { server.stop(); }); describe("normalizeCep", () => { test("accepts masked and unmasked", () => { for (const value of ["01001000", "01001-000", " 01001-000 ", "01001.000"]) { expect(normalizeCep(value)).toBe("01001000"); } }); test("rejects a bad format", () => { for (const value of ["", "123", "0100100a", "010010000", "abcdefgh"]) { expect(() => normalizeCep(value)).toThrow(InvalidCEP); } }); test("rejects a value that is not a string", () => { expect(() => normalizeCep(1001000 as unknown as string)).toThrow(InvalidCEP); }); test("throws something a single handler can catch", () => { expect(() => normalizeCep("123")).toThrow(CepisError); }); }); describe("lookup", () => { test("reads a successful lookup", async () => { server.routes.respond("/01001000", { status: 200, body: JSON.stringify(SE_ADDRESS), }); const address = await client.lookup("01001-000"); expect(address.localidade).toBe("São Paulo"); expect(address.uf).toBe("SP"); expect(address.formattedCep).toBe("01001-000"); expect(address.geo).toBeNull(); }); test("reads the coordinate when present", async () => { const body = { ...SE_ADDRESS, geo: { lat: -23.5, lon: -46.6, raio_m: 120, precisao: "logradouro" }, }; server.routes.respond("/01310100", { status: 200, body: JSON.stringify(body), }); const address = await client.lookup("01310100"); expect(address.geo).not.toBeNull(); expect(address.geo?.lat).toBeCloseTo(-23.5); expect(address.geo?.raio_m).toBe(120); expect(address.geo?.isPrecise).toBe(true); }); test("a municipality coordinate is not precise", async () => { const body = { ...SE_ADDRESS, geo: { lat: -23.5, lon: -46.6, raio_m: 22000, precisao: "localidade" }, }; server.routes.respond("/01001000", { status: 200, body: JSON.stringify(body), }); const address = await client.lookup("01001000"); expect(address.geo?.isPrecise).toBe(false); }); test("a malformed geo block does not lose the address", async () => { const body = { ...SE_ADDRESS, geo: { lat: "not a number" } }; server.routes.respond("/01001000", { status: 200, body: JSON.stringify(body), }); const address = await client.lookup("01001000"); expect(address.geo).toBeNull(); expect(address.localidade).toBe("São Paulo"); }); test("a new API field does not break the client", async () => { const body = { ...SE_ADDRESS, campo_do_futuro: "value" }; server.routes.respond("/01001000", { status: 200, body: JSON.stringify(body), }); const address = await client.lookup("01001000"); expect(address.raw.campo_do_futuro).toBe("value"); expect(address.localidade).toBe("São Paulo"); }); test("404 becomes CEPNotFound", async () => { server.routes.respond("/99999999", { status: 404, body: JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "gone" }), }); const promise = client.lookup("99999999"); await expect(promise).rejects.toBeInstanceOf(CEPNotFound); await expect(promise).rejects.toThrow("99999999"); }); test("404 is not retried", async () => { server.routes.respond("/99999999", { status: 404, body: JSON.stringify({ erro: "x" }), }); await expect(client.lookup("99999999")).rejects.toBeInstanceOf(CEPNotFound); expect(server.routes.count("/99999999")).toBe(1); }); test("an invalid CEP never leaves the machine", async () => { await expect(client.lookup("123")).rejects.toBeInstanceOf(InvalidCEP); expect(server.routes.calls.length).toBe(0); }); test("400 becomes InvalidCEP carrying the API's message", async () => { server.routes.respond("/00000000", { status: 400, body: JSON.stringify({ erro: "cep_invalido", mensagem: "rejected by the API", }), }); await expect(client.lookup("00000000")).rejects.toThrow("rejected by the API"); }); test("400 is not retried", async () => { server.routes.respond("/00000000", { status: 400, body: JSON.stringify({ erro: "x" }), }); await expect(client.lookup("00000000")).rejects.toBeInstanceOf(InvalidCEP); expect(server.routes.count("/00000000")).toBe(1); }); test("a response that is not JSON becomes InvalidResponse", async () => { server.routes.respond("/01001000", { status: 200, body: "captive portal", }); await expect(client.lookup("01001000")).rejects.toBeInstanceOf(InvalidResponse); }); test("a JSON array is not an address", async () => { server.routes.respond("/01001000", { status: 200, body: "[]" }); await expect(client.lookup("01001000")).rejects.toBeInstanceOf(InvalidResponse); }); test("sends a User-Agent identifying the library", async () => { const seen: string[] = []; const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { seen.push(request.headers.get("User-Agent") ?? ""); return new Response(JSON.stringify(SE_ADDRESS)); }, }); try { await new Client({ baseUrl: `http://127.0.0.1:${probe.port}` }).lookup("01001000"); } finally { probe.stop(true); } expect(seen[0]).toContain("cepis-typescript/0.2.0"); }); }); describe("faixa", () => { async function notFound(cep: string): Promise { try { await client.lookup(cep); } catch (error) { expect(error).toBeInstanceOf(CEPNotFound); return error as CEPNotFound; } throw new Error("expected CEPNotFound"); } test("names the municipality the CEP would belong to", async () => { server.routes.respond("/25845000", { status: 404, body: JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE", faixa: AREAL_FAIXA, }), }); const error = await notFound("25845000"); expect(error.faixa).toBeInstanceOf(Faixa); expect(error.faixa?.localidade).toBe("Areal"); expect(error.faixa?.uf).toBe("RJ"); expect(error.faixa?.estado).toBe("Rio de Janeiro"); expect(error.faixa?.regiao).toBe("Sudeste"); expect(error.faixa?.ibge).toBe("3300225"); expect(error.faixa?.label).toBe("Areal/RJ"); }); test("the range is what exists there, and it is sparse", async () => { server.routes.respond("/25845000", { status: 404, body: JSON.stringify({ erro: "cep_nao_encontrado", faixa: AREAL_FAIXA }), }); const error = await notFound("25845000"); // The DNE's own start for Areal is 25845000 — the CEP that just failed. expect(error.faixa?.cep_ini).toBe("25845001"); expect(error.faixa?.cep_fim).toBe("25849899"); // 118 CEPs across ~4,900 numbers: the pair is not a list of valid CEPs. expect(error.faixa?.ceps).toBe(118); }); test("a faixa is still a not-found, never an address", async () => { server.routes.respond("/25845000", { status: 404, body: JSON.stringify({ erro: "cep_nao_encontrado", faixa: AREAL_FAIXA }), }); const promise = client.lookup("25845000"); await expect(promise).rejects.toBeInstanceOf(CEPNotFound); await expect(promise).rejects.toThrow("25845000"); await expect(promise).rejects.toThrow("Areal/RJ"); expect(server.routes.count("/25845000")).toBe(1); }); test("a band no municipality owns omits the field", async () => { server.routes.respond("/99999999", { status: 404, body: JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE", }), }); const error = await notFound("99999999"); expect(error.faixa).toBeUndefined(); expect(error.cep).toBe("99999999"); expect(error.message).not.toContain("belongs to"); }); test("a faixa that cannot be read costs nothing", async () => { const useless: unknown[] = [null, "Areal", [], {}, { localidade: "" }, { uf: "RJ" }]; for (const faixa of useless) { server.routes.respond("/99999999", { status: 404, body: JSON.stringify({ erro: "cep_nao_encontrado", faixa }), }); const error = await notFound("99999999"); expect(error.faixa).toBeUndefined(); } }); test("a 404 that is not JSON still yields a clean not-found", async () => { server.routes.respond("/99999999", { status: 404, body: "captive portal", }); const error = await notFound("99999999"); expect(error.faixa).toBeUndefined(); }); test("a partial faixa keeps what it can", async () => { server.routes.respond("/99999999", { status: 404, body: JSON.stringify({ erro: "cep_nao_encontrado", faixa: { localidade: "Areal", ceps: "118" }, }), }); const error = await notFound("99999999"); expect(error.faixa?.localidade).toBe("Areal"); expect(error.faixa?.label).toBe("Areal"); expect(error.faixa?.ceps).toBe(118); expect(error.faixa?.cep_ini).toBe(""); }); }); describe("retrying", () => { test("retries a 500 and then succeeds", async () => { server.routes.respond("/01001000", [ { status: 500, body: JSON.stringify({ erro: "interno" }) }, { status: 200, body: JSON.stringify(SE_ADDRESS) }, ]); const address = await client.lookup("01001000"); expect(address.localidade).toBe("São Paulo"); expect(server.routes.count("/01001000")).toBe(2); }); test("exhausts the attempts and then throws", async () => { server.routes.respond("/01001000", { status: 503, body: JSON.stringify({ erro: "interno" }), }); try { await client.lookup("01001000"); throw new Error("expected ServerError"); } catch (error) { expect(error).toBeInstanceOf(ServerError); expect((error as ServerError).status).toBe(503); } expect(server.routes.count("/01001000")).toBe(3); }); test("maxAttempts of 1 disables retrying", async () => { const single = new Client({ baseUrl: server.url, maxAttempts: 1, timeoutMs: 5_000, }); server.routes.respond("/01001000", { status: 500, body: "{}" }); await expect(single.lookup("01001000")).rejects.toBeInstanceOf(ServerError); expect(server.routes.count("/01001000")).toBe(1); }); test("429 becomes RateLimited exposing Retry-After", async () => { server.routes.respond("/01001000", { status: 429, body: "{}", headers: { "Retry-After": "7" }, }); try { await client.lookup("01001000"); throw new Error("expected RateLimited"); } catch (error) { expect(error).toBeInstanceOf(RateLimited); expect((error as RateLimited).retryAfterMs).toBe(7_000); } }); test("an HTTP-date Retry-After is understood", async () => { const when = new Date(Date.now() + 4_000).toUTCString(); server.routes.respond("/01001000", { status: 429, body: "{}", headers: { "Retry-After": when }, }); try { await client.lookup("01001000"); throw new Error("expected RateLimited"); } catch (error) { expect(error).toBeInstanceOf(RateLimited); expect((error as RateLimited).retryAfterMs).toBeGreaterThan(0); expect((error as RateLimited).retryAfterMs).toBeLessThanOrEqual(5_000); } }); test("an absurd Retry-After is capped", async () => { server.routes.respond("/01001000", { status: 429, body: "{}", headers: { "Retry-After": "3600" }, }); const started = performance.now(); await expect(client.lookup("01001000")).rejects.toBeInstanceOf(RateLimited); expect(performance.now() - started).toBeLessThan(5_000); }); test("nothing listening becomes NetworkError", async () => { const unreachable = new Client({ baseUrl: "http://127.0.0.1:1", maxAttempts: 2, backoffBaseMs: 1, timeoutMs: 1_000, }); await expect(unreachable.lookup("01001000")).rejects.toBeInstanceOf(NetworkError); }); test("totalTimeoutMs bounds the whole call", async () => { const bounded = new Client({ baseUrl: server.url, maxAttempts: 50, backoffBaseMs: 50, backoffMaxMs: 50, totalTimeoutMs: 300, }); server.routes.respond("/01001000", { status: 500, body: "{}" }); const started = performance.now(); await expect(bounded.lookup("01001000")).rejects.toBeInstanceOf(ServerError); expect(performance.now() - started).toBeLessThan(3_000); expect(server.routes.count("/01001000")).toBeLessThan(50); }); }); describe("lookupMany", () => { test("preserves order and marks a missing CEP", async () => { const ceps = ["01001000", "99999999", "20040020"]; server.routes.respond(batchPath(ceps), { status: 200, body: csv([ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", "99999999,,,,,,,,,,,,", "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001", ]), }); const result = await client.lookupMany(ceps); expect(result.length).toBe(3); expect(result[0]?.localidade).toBe("São Paulo"); expect(result[1]).toBeNull(); expect(result[2]?.uf).toBe("RJ"); }); test("a batch never carries a coordinate", async () => { const ceps = ["01001000"]; server.routes.respond(batchPath(ceps), { status: 200, body: csv(["01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"]), }); const result = await client.lookupMany(ceps); expect(result[0]).toBeInstanceOf(Address); expect(result[0]?.geo).toBeNull(); }); test("a field containing a comma is parsed", async () => { const ceps = ["01001000"]; server.routes.respond(batchPath(ceps), { status: 200, body: csv(['01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1']), }); const result = await client.lookupMany(ceps); expect(result[0]?.logradouro).toBe("Rua Um, Dois"); }); test("an empty input makes no request", async () => { expect(await client.lookupMany([])).toEqual([]); expect(server.routes.calls.length).toBe(0); }); test("chunks above the API limit", async () => { const ceps = Array.from({ length: 501 }, (_, i) => String(i).padStart(8, "0")); server.routes.fallback = (path) => { const requested = new URL(`http://x${path}`).searchParams.get("ceps") ?? ""; const rows = requested .split(",") .map((cep) => `${cep},,,,,Cidade,SP,São Paulo,Sudeste,1,,11,1`); return { status: 200, body: csv(rows) }; }; const result = await client.lookupMany(ceps); expect(server.routes.calls.length).toBe(2); expect(result.length).toBe(501); expect(result[500]?.cep).toBe("00000500"); }); test("a truncated response pads rather than shifts", async () => { const ceps = ["01001000", "20040020"]; server.routes.respond(batchPath(ceps), { status: 200, body: csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"]), }); const result = await client.lookupMany(ceps); expect(result.length).toBe(2); expect(result[0]?.localidade).toBe("São Paulo"); expect(result[1]).toBeNull(); }); test("a malformed CEP is null, not an error", async () => { const ceps = ["01001000", "nope"]; server.routes.respond(batchPath(ceps), { status: 200, body: csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", "nope,,,,,,,,,,,,"]), }); const result = await client.lookupMany(ceps); expect(result[1]).toBeNull(); }); }); describe("health", () => { test("reads the report", async () => { server.routes.respond("/health", { status: 200, body: JSON.stringify({ status: "ok", dne: "26071", ceps: 1596168, geo: 894489, base_gerada_em: "2026-07-24T03:48:28Z", }), }); const report = await client.health(); expect(report.ok).toBe(true); expect(report.dne).toBe("26071"); expect(report.ceps).toBe(1596168); expect(report.base_gerada_em).toBe("2026-07-24T03:48:28Z"); }); test("a degraded API does not throw", async () => { server.routes.respond("/health", { status: 503, body: JSON.stringify({ status: "degradado", dne: "", ceps: 0, geo: 0 }), }); const report = await client.health(); expect(report.ok).toBe(false); expect(report.status).toBe("degradado"); expect(server.routes.count("/health")).toBe(1); }); }); describe("configuration", () => { test("a trailing slash is not duplicated", () => { expect(new Client({ baseUrl: "http://x/" }).baseUrl).toBe("http://x"); }); test("rejects impossible settings", () => { expect(() => new Client({ maxAttempts: 0 })).toThrow(RangeError); expect(() => new Client({ timeoutMs: 0 })).toThrow(RangeError); expect(() => new Client({ totalTimeoutMs: -1 })).toThrow(RangeError); }); test("the default User-Agent identifies the library", () => { expect(new Client().userAgent).toContain("cepis-typescript/"); }); }); describe("parseCsv", () => { test("handles quotes, escapes and CRLF", () => { const records = parseCsv('a,b\r\n"x,1","he said ""hi"""\r\n'); expect(records).toEqual([{ a: "x,1", b: 'he said "hi"' }]); }); test("fills columns a short row does not have", () => { expect(parseCsv("a,b,c\n1\n")).toEqual([{ a: "1", b: "", c: "" }]); }); test("an empty input yields no records", () => { expect(parseCsv("")).toEqual([]); }); });