summaryrefslogtreecommitdiffstats
path: root/test/api.zig
diff options
context:
space:
mode:
Diffstat (limited to 'test/api.zig')
-rw-r--r--test/api.zig898
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));
+}