//! Integration tests. //! //! These bring up a real HTTP server on loopback and point the client at it via //! `base_url`. No test touches the network and no test fakes the HTTP layer: //! the code path exercised is the one that runs in production, request //! construction and error body reading included. //! //! It doubles as the test that `base_url` works — if it did not, none of this //! would run. //! //! Every test allocates through `std.testing.allocator`, including the server, //! so a leak anywhere on either side fails the test rather than passing //! quietly. //! //! The fixture field names stay Portuguese because they mirror the API's JSON. const std = @import("std"); const cepis = @import("cepis"); const net = std.Io.net; const testing = std.testing; const se_address = \\{"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"; /// One canned response: status, body and extra headers. const Reply = struct { status: u16 = 200, body: []const u8 = "", headers: []const std.http.Header = &.{}, }; const Route = struct { prefix: []const u8, replies: []const Reply, served: usize = 0, }; /// A throwaway HTTP server on a random loopback port. /// /// Routes are matched by longest prefix, so `/csv` catches `/csv?ceps=...`. A /// route may hold several replies: each request consumes the next one, which is /// how retrying is tested, and the last one repeats once the list runs out. /// /// Connections are answered one request at a time and then closed, so a client /// that retries always finds the accept loop ready for it. const Server = struct { gpa: std.mem.Allocator, io: std.Io, listener: net.Server, address: net.IpAddress, url: []u8, thread: std.Thread, mutex: std.Io.Mutex = .init, routes: std.ArrayList(Route) = .empty, calls: std.ArrayList([]u8) = .empty, stopping: bool = false, fn start(gpa: std.mem.Allocator, io: std.Io) !*Server { const self = try gpa.create(Server); errdefer gpa.destroy(self); var wildcard: net.IpAddress = .{ .ip4 = .loopback(0) }; var listener = try wildcard.listen(io, .{ .reuse_address = true }); errdefer listener.deinit(io); const address = listener.socket.address; const url = try std.fmt.allocPrint(gpa, "http://127.0.0.1:{d}", .{address.getPort()}); errdefer gpa.free(url); self.* = .{ .gpa = gpa, .io = io, .listener = listener, .address = address, .url = url, .thread = undefined, }; self.thread = try std.Thread.spawn(.{}, run, .{self}); return self; } fn deinit(self: *Server) void { { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); self.stopping = true; } self.wake(); self.thread.join(); self.listener.deinit(self.io); const gpa = self.gpa; for (self.calls.items) |call| gpa.free(call); self.calls.deinit(gpa); self.routes.deinit(gpa); gpa.free(self.url); gpa.destroy(self); } /// Unblock the accept loop so the thread can notice `stopping`. fn wake(self: *Server) void { const stream = self.address.connect(self.io, .{ .mode = .stream }) catch return; stream.close(self.io); } fn respond(self: *Server, prefix: []const u8, replies: []const Reply) !void { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); try self.routes.append(self.gpa, .{ .prefix = prefix, .replies = replies }); } /// A client pointed at this server, with the backoff shortened so a test /// that exhausts its attempts finishes in milliseconds. fn client(self: *Server, config: cepis.Config) !cepis.Client { const defaults: cepis.Config = .{}; var full = config; full.base_url = self.url; if (full.backoff_base_ms == defaults.backoff_base_ms) full.backoff_base_ms = 1; if (full.backoff_max_ms == defaults.backoff_max_ms) full.backoff_max_ms = 10; if (full.timeout_ms == defaults.timeout_ms) full.timeout_ms = 5_000; return cepis.Client.init(self.gpa, self.io, full); } fn callCount(self: *Server) usize { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); return self.calls.items.len; } fn countPrefix(self: *Server, prefix: []const u8) usize { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); var n: usize = 0; for (self.calls.items) |call| { if (std.mem.startsWith(u8, call, prefix)) n += 1; } return n; } fn lastCall(self: *Server) ?[]const u8 { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); if (self.calls.items.len == 0) return null; return self.calls.items[self.calls.items.len - 1]; } fn run(self: *Server) void { while (true) { const stream = self.listener.accept(self.io) catch break; defer stream.close(self.io); { self.mutex.lockUncancelable(self.io); const stop = self.stopping; self.mutex.unlock(self.io); if (stop) break; } self.handle(stream) catch {}; } } fn handle(self: *Server, stream: net.Stream) !void { var read_buffer: [16 * 1024]u8 = undefined; var write_buffer: [16 * 1024]u8 = undefined; var reader = stream.reader(self.io, &read_buffer); var writer = stream.writer(self.io, &write_buffer); var http_server = std.http.Server.init(&reader.interface, &writer.interface); var request = try http_server.receiveHead(); const reply = try self.record(request.head.target); try request.respond(reply.body, .{ .status = @enumFromInt(reply.status), .extra_headers = reply.headers, .keep_alive = false, }); } /// Log the call and pick the reply for it, under the lock. fn record(self: *Server, target: []const u8) !Reply { self.mutex.lockUncancelable(self.io); defer self.mutex.unlock(self.io); const call = try self.gpa.dupe(u8, target); errdefer self.gpa.free(call); try self.calls.append(self.gpa, call); var best: ?*Route = null; for (self.routes.items) |*route| { if (!std.mem.startsWith(u8, target, route.prefix)) continue; if (best == null or route.prefix.len > best.?.prefix.len) best = route; } const route = best orelse return .{ .status = 404, .body = \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} , }; const index = @min(route.served, route.replies.len - 1); route.served += 1; return route.replies[index]; } }; /// Everything a test needs: an `Io` and a server on loopback, both allocating /// through `std.testing.allocator`. const Fixture = struct { threaded: std.Io.Threaded, server: *Server, fn init(self: *Fixture) !void { self.threaded = .init(testing.allocator, .{}); errdefer self.threaded.deinit(); self.server = try Server.start(testing.allocator, self.threaded.io()); } fn deinit(self: *Fixture) void { self.server.deinit(); self.threaded.deinit(); } }; /// Milliseconds since `started`, on the monotonic clock. fn elapsedMs(io: std.Io, started: std.Io.Timestamp) u64 { const now = std.Io.Timestamp.now(io, .awake); const ns = started.durationTo(now).nanoseconds; if (ns <= 0) return 0; return @intCast(@divFloor(ns, std.time.ns_per_ms)); } fn csvBody(gpa: std.mem.Allocator, rows: []const []const u8) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(gpa); try out.appendSlice(gpa, csv_header); for (rows) |row| { try out.append(gpa, '\n'); try out.appendSlice(gpa, row); } try out.append(gpa, '\n'); return out.toOwnedSlice(gpa); } test "a successful lookup" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .body = se_address }}); var client = try fixture.server.client(.{}); defer client.deinit(); var address = try client.lookup("01001-000", .{}); defer address.deinit(testing.allocator); try testing.expectEqualStrings("São Paulo", address.localidade); try testing.expectEqualStrings("SP", address.uf); try testing.expectEqualStrings("Praça da Sé", address.logradouro); try testing.expect(address.geo == null); var buffer: [16]u8 = undefined; try testing.expectEqualStrings("01001-000", address.formattedCep(&buffer)); } test "the coordinate is read when present" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01310100", &.{.{ .body = \\{"cep":"01310100","logradouro":"Avenida Paulista","localidade":"São Paulo", \\"uf":"SP","geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var address = try client.lookup("01310100", .{}); defer address.deinit(testing.allocator); const geo = address.geo.?; try testing.expectApproxEqAbs(@as(f64, -23.5), geo.lat, 1e-9); try testing.expectApproxEqAbs(@as(f64, -46.6), geo.lon, 1e-9); try testing.expectEqual(@as(i64, 120), geo.raio_m); try testing.expect(geo.isPrecise()); } test "a municipality coordinate is not precise" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/69900000", &.{.{ .body = \\{"cep":"69900000","localidade":"Rio Branco","uf":"AC", \\"geo":{"lat":-9.9,"lon":-67.8,"raio_m":22000,"precisao":"localidade"}} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var address = try client.lookup("69900000", .{}); defer address.deinit(testing.allocator); try testing.expect(!address.geo.?.isPrecise()); } test "a malformed geo block does not lose the address" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .body = \\{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var address = try client.lookup("01001000", .{}); defer address.deinit(testing.allocator); try testing.expect(address.geo == null); try testing.expectEqualStrings("São Paulo", address.localidade); } test "a new API field does not break the client" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .body = \\{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var address = try client.lookup("01001000", .{}); defer address.deinit(testing.allocator); try testing.expectEqualStrings("São Paulo", address.localidade); try testing.expect(std.mem.indexOf(u8, address.raw, "campo_do_futuro") != null); } test "a missing CEP is not found" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/99999999", &.{.{ .status = 404, .body = \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.NotFound, client.lookup("99999999", .{ .diagnostics = &diag })); try testing.expect(!cepis.isRetriable(error.NotFound)); try testing.expect(std.mem.indexOf(u8, diag.message(), "99999999") != null); } test "a missing CEP names the municipality it would have been in" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); // 25845000 is Areal's own start in the DNE — and it is not a CEP that // exists. That is exactly the guess this field is there to answer. try fixture.server.respond("/25845000", &.{.{ .status = 404, .body = \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", \\"faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro", \\"regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001", \\"cep_fim":"25849899","ceps":118}} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; // Still an error. The range explains the failure; it does not undo it. try testing.expectError(error.NotFound, client.lookup("25845-000", .{ .diagnostics = &diag })); try testing.expectEqual(@as(?u16, 404), diag.status); const faixa = diag.faixa().?; try testing.expectEqualStrings("Areal", faixa.localidade); try testing.expectEqualStrings("RJ", faixa.uf); try testing.expectEqualStrings("Rio de Janeiro", faixa.estado); try testing.expectEqualStrings("Sudeste", faixa.regiao); try testing.expectEqualStrings("3300225", faixa.ibge); // Not the Correios' bounds: cep_ini is the smallest CEP that exists, and // the CEP that just failed is one below it. try testing.expectEqualStrings("25845001", faixa.cep_ini); try testing.expectEqualStrings("25849899", faixa.cep_fim); // Sparse: 118 CEPs scattered over the ~4,900 numbers the pair spans. try testing.expectEqual(@as(u64, 118), faixa.ceps); } test "a CEP in a band no municipality owns reports no faixa" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); // The API omits `faixa` here; it never sends null. try fixture.server.respond("/99999999", &.{.{ .status = 404, .body = \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.NotFound, client.lookup("99999999", .{ .diagnostics = &diag })); try testing.expect(diag.faixa() == null); try testing.expect(std.mem.indexOf(u8, diag.message(), "99999999") != null); } test "a malformed faixa is still a clean not found" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/99999999", &.{ .{ .status = 404, .body = "{\"erro\":\"cep_nao_encontrado\",\"faixa\":\"Areal\"}" }, .{ .status = 404, .body = "{\"faixa\":{\"uf\":\"RJ\",\"ceps\":118}}" }, .{ .status = 404, .body = "not json at all" }, .{ .status = 404, .body = "" }, }); var client = try fixture.server.client(.{}); defer client.deinit(); for (0..4) |_| { var diag: cepis.Diagnostics = .{}; try testing.expectError( error.NotFound, client.lookup("99999999", .{ .diagnostics = &diag }), ); try testing.expect(diag.faixa() == null); } } test "one Diagnostics reused across lookups does not keep a stale faixa" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/25845000", &.{.{ .status = 404, .body = \\{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ", \\"ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}} , }}); try fixture.server.respond("/99999999", &.{.{ .status = 404, .body = \\{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.NotFound, client.lookup("25845000", .{ .diagnostics = &diag })); try testing.expectEqualStrings("Areal", diag.faixa().?.localidade); try testing.expectError(error.NotFound, client.lookup("99999999", .{ .diagnostics = &diag })); try testing.expect(diag.faixa() == null); } test "a successful lookup carries no faixa" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .body = se_address }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; var address = try client.lookup("01001000", .{ .diagnostics = &diag }); defer address.deinit(testing.allocator); try testing.expect(diag.faixa() == null); } test "a 404 is not retried" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/99999999", &.{.{ .status = 404, .body = "{}" }}); var client = try fixture.server.client(.{}); defer client.deinit(); try testing.expectError(error.NotFound, client.lookup("99999999", .{})); try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/99999999")); } test "an invalid CEP never leaves the machine" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.InvalidCep, client.lookup("123", .{ .diagnostics = &diag })); try testing.expectEqual(@as(usize, 0), fixture.server.callCount()); try testing.expect(std.mem.indexOf(u8, diag.message(), "8 digits") != null); } test "a 400 from the API becomes InvalidCep with its own wording" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/00000000", &.{.{ .status = 400, .body = \\{"erro":"cep_invalido","mensagem":"rejected by the API"} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.InvalidCep, client.lookup("00000000", .{ .diagnostics = &diag })); try testing.expectEqualStrings("rejected by the API", diag.message()); try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/00000000")); } test "a response that is not JSON is an invalid response" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .body = "captive portal" }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError( error.InvalidResponse, client.lookup("01001000", .{ .diagnostics = &diag }), ); try testing.expect(std.mem.indexOf(u8, diag.message(), "captive portal") != null); } test "a 500 is retried and then succeeds" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{ .{ .status = 500, .body = "{\"erro\":\"interno\"}" }, .{ .body = se_address }, }); var client = try fixture.server.client(.{}); defer client.deinit(); var address = try client.lookup("01001000", .{}); defer address.deinit(testing.allocator); try testing.expectEqualStrings("São Paulo", address.localidade); try testing.expectEqual(@as(usize, 2), fixture.server.countPrefix("/01001000")); } test "the attempts are exhausted and then it fails" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .status = 503, .body = "{\"erro\":\"interno\"}" }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.ServerError, client.lookup("01001000", .{ .diagnostics = &diag })); try testing.expectEqual(@as(?u16, 503), diag.status); try testing.expectEqual(@as(u32, 3), diag.attempts); try testing.expect(cepis.isRetriable(error.ServerError)); try testing.expectEqual(@as(usize, 3), fixture.server.countPrefix("/01001000")); } test "max_attempts of one disables retrying" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .status = 500, .body = "{}" }}); var client = try fixture.server.client(.{ .max_attempts = 1 }); defer client.deinit(); try testing.expectError(error.ServerError, client.lookup("01001000", .{})); try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/01001000")); } test "a 429 reports the Retry-After it was given" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .status = 429, .body = "{}", .headers = &.{.{ .name = "retry-after", .value = "7" }}, }}); var client = try fixture.server.client(.{}); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.RateLimited, client.lookup("01001000", .{ .diagnostics = &diag })); try testing.expectEqual(@as(?u64, 7000), diag.retry_after_ms); } test "a huge Retry-After is capped so the caller is not parked" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .status = 429, .body = "{}", .headers = &.{.{ .name = "Retry-After", .value = "3600" }}, }}); var client = try fixture.server.client(.{}); defer client.deinit(); const started = std.Io.Timestamp.now(fixture.threaded.io(), .awake); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.RateLimited, client.lookup("01001000", .{ .diagnostics = &diag })); try testing.expectEqual(@as(?u64, 3_600_000), diag.retry_after_ms); try testing.expect(elapsedMs(fixture.threaded.io(), started) < 5_000); } test "a Retry-After as an HTTP date is understood" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .status = 429, .body = "{}", .headers = &.{.{ .name = "retry-after", .value = "Wed, 21 Oct 2099 07:28:00 GMT" }}, }}); var client = try fixture.server.client(.{}); defer client.deinit(); const started = std.Io.Timestamp.now(fixture.threaded.io(), .awake); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.RateLimited, client.lookup("01001000", .{ .diagnostics = &diag })); try testing.expect(diag.retry_after_ms.? > 86_400_000); try testing.expect(elapsedMs(fixture.threaded.io(), started) < 5_000); } test "nothing listening is a network error" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); var client = try cepis.Client.init(testing.allocator, threaded.io(), .{ .base_url = "http://127.0.0.1:1", .max_attempts = 2, .backoff_base_ms = 1, .backoff_max_ms = 5, .timeout_ms = 1000, }); defer client.deinit(); var diag: cepis.Diagnostics = .{}; try testing.expectError(error.NetworkError, client.lookup("01001000", .{ .diagnostics = &diag })); try testing.expect(cepis.isRetriable(error.NetworkError)); try testing.expectEqual(@as(u32, 2), diag.attempts); } test "total_timeout bounds the whole call" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/01001000", &.{.{ .status = 500, .body = "{}" }}); var client = try fixture.server.client(.{ .max_attempts = 50, .backoff_base_ms = 50, .backoff_max_ms = 50, .total_timeout_ms = 300, }); defer client.deinit(); const started = std.Io.Timestamp.now(fixture.threaded.io(), .awake); try testing.expectError(error.ServerError, client.lookup("01001000", .{})); try testing.expect(elapsedMs(fixture.threaded.io(), started) < 5_000); try testing.expect(fixture.server.countPrefix("/01001000") < 50); } test "the batch keeps order and marks the missing one" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); const body = try csvBody(testing.allocator, &.{ "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", }); defer testing.allocator.free(body); try fixture.server.respond("/csv", &.{.{ .body = body }}); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(&.{ "01001000", "99999999", "20040020" }, .{}); defer cepis.freeAddresses(testing.allocator, result); try testing.expectEqual(@as(usize, 3), result.len); try testing.expectEqualStrings("São Paulo", result[0].?.localidade); try testing.expect(result[1] == null); try testing.expectEqualStrings("RJ", result[2].?.uf); try testing.expectEqualStrings("Avenida Rio Branco", result[2].?.logradouro); } test "the batch carries no coordinate" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); const body = try csvBody(testing.allocator, &.{ "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", }); defer testing.allocator.free(body); try fixture.server.respond("/csv", &.{.{ .body = body }}); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(&.{"01001000"}, .{}); defer cepis.freeAddresses(testing.allocator, result); try testing.expect(result[0].?.geo == null); try testing.expectEqualStrings("", result[0].?.raw); } test "the batch parses a field containing a comma" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); const body = try csvBody(testing.allocator, &.{ "01001000,\"Rua Um, Dois\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1", }); defer testing.allocator.free(body); try fixture.server.respond("/csv", &.{.{ .body = body }}); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(&.{"01001000"}, .{}); defer cepis.freeAddresses(testing.allocator, result); try testing.expectEqualStrings("Rua Um, Dois", result[0].?.logradouro); } test "a malformed CEP in a batch is null, not an error" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); const body = try csvBody(testing.allocator, &.{ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", "nope,,,,,,,,,,,,", }); defer testing.allocator.free(body); try fixture.server.respond("/csv", &.{.{ .body = body }}); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(&.{ "01001000", "no pe&" }, .{}); defer cepis.freeAddresses(testing.allocator, result); try testing.expect(result[0] != null); try testing.expect(result[1] == null); try testing.expect(std.mem.indexOf(u8, fixture.server.lastCall().?, "no%20pe%26") != null); } test "an empty batch makes no request" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(&.{}, .{}); defer cepis.freeAddresses(testing.allocator, result); try testing.expectEqual(@as(usize, 0), result.len); try testing.expectEqual(@as(usize, 0), fixture.server.callCount()); } test "the batch chunks above the API limit" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); const gpa = testing.allocator; const total = cepis.max_batch + 1; var ceps: std.ArrayList([]const u8) = .empty; defer { for (ceps.items) |cep| gpa.free(cep); ceps.deinit(gpa); } var rows: std.ArrayList([]const u8) = .empty; defer { for (rows.items) |row| gpa.free(row); rows.deinit(gpa); } for (0..total) |i| { try ceps.append(gpa, try std.fmt.allocPrint(gpa, "{d:0>8}", .{i})); try rows.append(gpa, try std.fmt.allocPrint( gpa, "{d:0>8},,,,,X,SP,São Paulo,Sudeste,1,,11,1", .{i}, )); } const first = try csvBody(gpa, rows.items[0..cepis.max_batch]); defer gpa.free(first); const second = try csvBody(gpa, rows.items[cepis.max_batch..]); defer gpa.free(second); try fixture.server.respond("/csv", &.{ .{ .body = first }, .{ .body = second } }); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(ceps.items, .{}); defer cepis.freeAddresses(gpa, result); try testing.expectEqual(total, result.len); try testing.expectEqual(@as(usize, 2), fixture.server.countPrefix("/csv")); for (result) |slot| try testing.expect(slot != null); try testing.expectEqualStrings("00000500", result[cepis.max_batch].?.cep); } test "a truncated batch pads instead of shifting" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); const body = try csvBody(testing.allocator, &.{ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", }); defer testing.allocator.free(body); try fixture.server.respond("/csv", &.{.{ .body = body }}); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(&.{ "01001000", "20040020" }, .{}); defer cepis.freeAddresses(testing.allocator, result); try testing.expectEqual(@as(usize, 2), result.len); try testing.expect(result[0] != null); try testing.expect(result[1] == null); } test "the batch is retried like any other request" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); const body = try csvBody(testing.allocator, &.{ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", }); defer testing.allocator.free(body); try fixture.server.respond("/csv", &.{ .{ .status = 502, .body = "{}" }, .{ .body = body }, }); var client = try fixture.server.client(.{}); defer client.deinit(); const result = try client.lookupMany(&.{"01001000"}, .{}); defer cepis.freeAddresses(testing.allocator, result); try testing.expect(result[0] != null); try testing.expectEqual(@as(usize, 2), fixture.server.countPrefix("/csv")); } test "health reads the database version" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/health", &.{.{ .body = \\{"status":"ok","dne":"26071","ceps":1596168,"geo":894489,"base_gerada_em":"2026-07-24T03:48:28Z"} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var health = try client.health(.{}); defer health.deinit(testing.allocator); try testing.expect(health.ok()); try testing.expectEqualStrings("26071", health.dne); try testing.expectEqual(@as(u64, 1_596_168), health.ceps); try testing.expectEqual(@as(u64, 894_489), health.geo); try testing.expectEqualStrings("2026-07-24T03:48:28Z", health.base_gerada_em); } test "a degraded API is not an error" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try fixture.server.respond("/health", &.{.{ .status = 503, .body = \\{"status":"degradado","dne":"","ceps":0,"geo":0} , }}); var client = try fixture.server.client(.{}); defer client.deinit(); var health = try client.health(.{}); defer health.deinit(testing.allocator); try testing.expect(!health.ok()); try testing.expectEqual(@as(usize, 1), fixture.server.countPrefix("/health")); } test "a trailing slash on base_url is not duplicated" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); var client = try cepis.Client.init(testing.allocator, threaded.io(), .{ .base_url = "http://example.invalid/", }); defer client.deinit(); try testing.expectEqualStrings("http://example.invalid", client.base_url); } test "the User-Agent identifies the library and can be overridden" { var fixture: Fixture = undefined; try fixture.init(); defer fixture.deinit(); try testing.expect(std.mem.startsWith(u8, cepis.default_user_agent, "cepis-zig/0.2.0")); try fixture.server.respond("/01001000", &.{.{ .body = se_address }}); var client = try fixture.server.client(.{ .user_agent = "my-app/1.0" }); defer client.deinit(); var address = try client.lookup("01001000", .{}); defer address.deinit(testing.allocator); try testing.expectEqualStrings("my-app/1.0", client.user_agent); } test "a zero max_attempts or timeout is a configuration error" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); try testing.expectError(error.InvalidConfiguration, cepis.Client.init( testing.allocator, threaded.io(), .{ .max_attempts = 0 }, )); try testing.expectError(error.InvalidConfiguration, cepis.Client.init( testing.allocator, threaded.io(), .{ .timeout_ms = 0 }, )); } test "normalizeCep is reachable from the public surface" { var buffer: [8]u8 = undefined; try testing.expectEqualStrings("01001000", try cepis.normalizeCep("01001-000", &buffer)); try testing.expectError(error.InvalidCep, cepis.normalizeCep("123", &buffer)); }