summaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:15:55 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:15:55 -0300
commit986a16c34049187a6c1f39f38bfa59e327093494 (patch)
tree0c0bab4521fa3e8a4245b534505f56816a6f250b /test
downloadtypescript-986a16c34049187a6c1f39f38bfa59e327093494.tar.gz
Initial importv0.1.0
Diffstat (limited to 'test')
-rw-r--r--test/cepis.test.ts474
-rw-r--r--test/server.ts97
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);
+ },
+ };
+}