diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:48:43 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:48:43 -0300 |
| commit | 87166a5299aa551517a86379a0c05eb2110b52b0 (patch) | |
| tree | 3e77e7dd89cca736a22a5f2219451aa57dd99068 /test/api.zig | |
| download | zig-87166a5299aa551517a86379a0c05eb2110b52b0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'test/api.zig')
| -rw-r--r-- | test/api.zig | 898 |
1 files changed, 898 insertions, 0 deletions
diff --git a/test/api.zig b/test/api.zig new file mode 100644 index 0000000..fcdb956 --- /dev/null +++ b/test/api.zig @@ -0,0 +1,898 @@ +//! 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 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 = "<html>captive portal</html>" }}); + + 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.1.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)); +} |