diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/cepis.test.ts | 474 | ||||
| -rw-r--r-- | test/server.ts | 97 |
2 files changed, 571 insertions, 0 deletions
diff --git a/test/cepis.test.ts b/test/cepis.test.ts new file mode 100644 index 0000000..911aa1d --- /dev/null +++ b/test/cepis.test.ts @@ -0,0 +1,474 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + Address, + CEPNotFound, + CepisError, + Client, + InvalidCEP, + InvalidResponse, + NetworkError, + RateLimited, + ServerError, + normalizeCep, + parseCsv, +} from "../src/index.js"; +import { startServer, type FakeServer } from "./server.js"; + +const SE_ADDRESS: Record<string, unknown> = { + 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 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: "<html>captive portal</html>", + }); + 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.1.0"); + }); +}); + +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([]); + }); +}); diff --git a/test/server.ts b/test/server.ts new file mode 100644 index 0000000..c242f52 --- /dev/null +++ b/test/server.ts @@ -0,0 +1,97 @@ +/** + * A throwaway HTTP server on a random loopback port. + * + * The suite points a real {@link Client} at a real socket: no test touches the + * network, and no test stubs `fetch`. The code path exercised is the one that + * runs in production, request construction and error body reading included. + * + * It doubles as the test that `baseUrl` works — if it did not, none of this + * would run. + * + * @module + */ + +/** What the fake server should answer for one request. */ +export interface Reply { + status: number; + body: string; + headers?: Record<string, string>; +} + +/** Per-test programmable routes, plus a request counter. */ +export class Router { + private readonly routes = new Map<string, Reply | Reply[]>(); + /** Every path requested so far, in order. */ + readonly calls: string[] = []; + /** Answers any path with no registered route. */ + fallback?: (path: string) => Reply; + + /** + * Register the answer for a path. + * + * An array makes each request consume the next entry, which is how retrying + * is tested. The last entry repeats once the array runs out. + */ + respond(path: string, reply: Reply | Reply[]): void { + this.routes.set(path, reply); + } + + /** Record the call and return the answer registered for `path`. */ + next(path: string): Reply { + this.calls.push(path); + const reply = this.routes.get(path); + if (reply === undefined) { + if (this.fallback !== undefined) { + return this.fallback(path); + } + return { + status: 404, + body: JSON.stringify({ erro: "cep_nao_encontrado" }), + }; + } + if (Array.isArray(reply)) { + return (reply.length > 1 ? reply.shift() : reply[0]) as Reply; + } + return reply; + } + + /** How many requests so far began with `prefix`. */ + count(prefix: string): number { + return this.calls.filter((call) => call.startsWith(prefix)).length; + } +} + +/** A running fake API, with its router. */ +export interface FakeServer { + url: string; + routes: Router; + stop: () => void; +} + +/** Start a fake API on `127.0.0.1` and a port the kernel picks. */ +export function startServer(): FakeServer { + const routes = new Router(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + const reply = routes.next(`${url.pathname}${url.search}`); + return new Response(reply.body, { + status: reply.status, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...reply.headers, + }, + }); + }, + }); + + return { + url: `http://127.0.0.1:${server.port}`, + routes, + stop: () => { + server.stop(true); + }, + }; +} |