From 8e719fc715eb7ae9aa1c842e633f15b5c6cd3688 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:38:22 -0300 Subject: Initial import --- test/cepis_test.dart | 632 +++++++++++++++++++++++++++++++++++++++++++++++++++ test/server.dart | 126 ++++++++++ 2 files changed, 758 insertions(+) create mode 100644 test/cepis_test.dart create mode 100644 test/server.dart (limited to 'test') diff --git a/test/cepis_test.dart b/test/cepis_test.dart new file mode 100644 index 0000000..54df88f --- /dev/null +++ b/test/cepis_test.dart @@ -0,0 +1,632 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cepis/cepis.dart'; +import 'package:cepis/src/csv.dart'; +import 'package:test/test.dart'; + +import 'server.dart'; + +const Map seAddress = { + '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 String csvHeader = + 'cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,' + 'ibge,gia,ddd,siafi'; + +String csv(List rows) => '$csvHeader\n${rows.join('\n')}\n'; + +String batchPath(List ceps) { + final query = Uri( + queryParameters: {'ceps': ceps.join(',')}, + ).query; + return '/csv?$query'; +} + +Map withGeo(Map geo) => + {...seAddress, 'geo': geo}; + +void main() { + late FakeServer server; + late Client client; + + setUp(() async { + server = await FakeServer.start(); + client = Client( + baseUrl: server.url, + timeout: const Duration(seconds: 5), + backoffBase: const Duration(milliseconds: 1), + backoffMax: const Duration(milliseconds: 10), + ); + }); + + tearDown(() async { + client.close(); + await server.stop(); + }); + + group('normalizeCep', () { + test('accepts masked and unmasked', () { + for (final String value in [ + '01001000', + '01001-000', + ' 01001-000 ', + '01001.000', + ]) { + expect(normalizeCep(value), '01001000'); + } + }); + + test('rejects a bad format', () { + for (final String value in [ + '', + '123', + '0100100a', + '010010000', + 'abcdefgh', + ]) { + expect( + () => normalizeCep(value), + throwsA(isA()), + reason: value, + ); + } + }); + + test('throws something a single handler can catch', () { + expect(() => normalizeCep('123'), throwsA(isA())); + }); + + test('hooks into FormatException', () { + expect(() => normalizeCep('123'), throwsFormatException); + }); + }); + + group('lookup', () { + test('reads a successful lookup', () async { + server.routes.respond('/01001000', Reply(200, jsonEncode(seAddress))); + + final address = await client.lookup('01001-000'); + expect(address.localidade, 'São Paulo'); + expect(address.uf, 'SP'); + expect(address.logradouro, 'Praça da Sé'); + expect(address.formattedCep, '01001-000'); + expect(address.geo, isNull); + }); + + test('reads the coordinate when present', () async { + server.routes.respond( + '/01310100', + Reply( + 200, + jsonEncode( + withGeo({ + 'lat': -23.5, + 'lon': -46.6, + 'raio_m': 120, + 'precisao': 'logradouro', + }), + ), + ), + ); + + final address = await client.lookup('01310100'); + expect(address.geo, isNotNull); + expect(address.geo!.lat, closeTo(-23.5, 1e-9)); + expect(address.geo!.lon, closeTo(-46.6, 1e-9)); + expect(address.geo!.raioM, 120); + expect(address.geo!.isPrecise, isTrue); + }); + + test('a municipality coordinate is not precise', () async { + server.routes.respond( + '/01001000', + Reply( + 200, + jsonEncode( + withGeo({ + 'lat': -23.5, + 'lon': -46.6, + 'raio_m': 22000, + 'precisao': 'localidade', + }), + ), + ), + ); + + final address = await client.lookup('01001000'); + expect(address.geo!.isPrecise, isFalse); + expect(address.geo!.raioM, 22000); + }); + + test('a malformed geo block does not lose the address', () async { + server.routes.respond( + '/01001000', + Reply( + 200, + jsonEncode(withGeo({'lat': 'not a number'})), + ), + ); + + final address = await client.lookup('01001000'); + expect(address.geo, isNull); + expect(address.localidade, 'São Paulo'); + }); + + test('a new API field does not break the client', () async { + server.routes.respond( + '/01001000', + Reply( + 200, + jsonEncode({ + ...seAddress, + 'campo_do_futuro': 'value', + }), + ), + ); + + final address = await client.lookup('01001000'); + expect(address.raw['campo_do_futuro'], 'value'); + expect(address.localidade, 'São Paulo'); + }); + + test('404 becomes CepNotFoundException and is not retried', () async { + server.routes.respond( + '/99999999', + Reply( + 404, + jsonEncode({ + 'erro': 'cep_nao_encontrado', + 'mensagem': 'CEP não existe na base do DNE', + }), + ), + ); + + await expectLater( + client.lookup('99999999'), + throwsA( + isA().having( + (CepNotFoundException e) => e.cep, + 'cep', + '99999999', + ), + ), + ); + expect(server.routes.count('/99999999'), 1); + }); + + test('an invalid CEP never leaves the machine', () async { + await expectLater( + client.lookup('123'), + throwsA(isA()), + ); + expect(server.routes.calls, isEmpty); + }); + + test('400 becomes InvalidCepException carrying the API message', () async { + server.routes.respond( + '/00000000', + Reply( + 400, + jsonEncode({ + 'erro': 'cep_invalido', + 'mensagem': 'rejected by the API', + }), + ), + ); + + await expectLater( + client.lookup('00000000'), + throwsA( + isA().having( + (InvalidCepException e) => e.message, + 'message', + 'rejected by the API', + ), + ), + ); + expect(server.routes.count('/00000000'), 1); + }); + + test('a response that is not JSON becomes InvalidResponseException', + () async { + server.routes + .respond('/01001000', const Reply(200, 'portal')); + + await expectLater( + client.lookup('01001000'), + throwsA(isA()), + ); + }); + + test('a JSON array is not an address', () async { + server.routes.respond('/01001000', const Reply(200, '[]')); + + await expectLater( + client.lookup('01001000'), + throwsA(isA()), + ); + }); + + test('sends a User-Agent identifying the library', () async { + server.routes.respond('/01001000', Reply(200, jsonEncode(seAddress))); + + await client.lookup('01001000'); + expect(server.routes.userAgents.single, startsWith('cepis-dart/0.1.0')); + }); + }); + + group('retrying', () { + test('retries a 500 and then succeeds', () async { + server.routes.respondInSequence('/01001000', [ + Reply(500, jsonEncode({'erro': 'interno'})), + Reply(200, jsonEncode(seAddress)), + ]); + + final address = await client.lookup('01001000'); + expect(address.localidade, 'São Paulo'); + expect(server.routes.count('/01001000'), 2); + }); + + test('exhausts the attempts and then throws', () async { + server.routes.respond( + '/01001000', + Reply(503, jsonEncode({'erro': 'interno'})), + ); + + await expectLater( + client.lookup('01001000'), + throwsA( + isA().having( + (ServerException e) => e.status, + 'status', + 503, + ), + ), + ); + expect(server.routes.count('/01001000'), 3); + }); + + test('maxAttempts of 1 disables retrying', () async { + final single = Client(baseUrl: server.url, maxAttempts: 1); + addTearDown(single.close); + + server.routes.respond('/01001000', const Reply(500, '{}')); + + await expectLater( + single.lookup('01001000'), + throwsA(isA()), + ); + expect(server.routes.count('/01001000'), 1); + }); + + test('429 becomes RateLimitedException exposing Retry-After', () async { + server.routes.respond( + '/01001000', + const Reply(429, '{}', headers: {'Retry-After': '7'}), + ); + + await expectLater( + client.lookup('01001000'), + throwsA( + isA().having( + (RateLimitedException e) => e.retryAfter, + 'retryAfter', + const Duration(seconds: 7), + ), + ), + ); + }); + + test('an HTTP-date Retry-After is understood', () async { + final when = HttpDate.format( + DateTime.now().add(const Duration(seconds: 4)), + ); + server.routes.respond( + '/01001000', + Reply(429, '{}', headers: {'Retry-After': when}), + ); + + await expectLater( + client.lookup('01001000'), + throwsA( + isA().having( + (RateLimitedException e) => e.retryAfter, + 'retryAfter', + allOf( + greaterThan(Duration.zero), + lessThanOrEqualTo(const Duration(seconds: 5)), + ), + ), + ), + ); + }); + + test('an absurd Retry-After is capped', () async { + server.routes.respond( + '/01001000', + const Reply( + 429, + '{}', + headers: {'Retry-After': '3600'}, + ), + ); + + final started = Stopwatch()..start(); + await expectLater( + client.lookup('01001000'), + throwsA(isA()), + ); + expect(started.elapsed, lessThan(const Duration(seconds: 5))); + }); + + test('nothing listening becomes NetworkException', () async { + final unreachable = Client( + baseUrl: 'http://127.0.0.1:1', + maxAttempts: 2, + backoffBase: const Duration(milliseconds: 1), + timeout: const Duration(seconds: 2), + ); + addTearDown(unreachable.close); + + await expectLater( + unreachable.lookup('01001000'), + throwsA(isA()), + ); + }); + + test('totalTimeout bounds the whole call', () async { + final bounded = Client( + baseUrl: server.url, + maxAttempts: 50, + backoffBase: const Duration(milliseconds: 50), + backoffMax: const Duration(milliseconds: 50), + totalTimeout: const Duration(milliseconds: 300), + ); + addTearDown(bounded.close); + + server.routes.respond('/01001000', const Reply(500, '{}')); + + final started = Stopwatch()..start(); + await expectLater( + bounded.lookup('01001000'), + throwsA(isA()), + ); + expect(started.elapsed, lessThan(const Duration(seconds: 5))); + expect(server.routes.count('/01001000'), lessThan(50)); + }); + }); + + group('lookupMany', () { + test('preserves order and marks a missing CEP', () async { + final ceps = ['01001000', '99999999', '20040020']; + server.routes.respond( + batchPath(ceps), + Reply( + 200, + 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', + ]), + ), + ); + + final result = await client.lookupMany(ceps); + expect(result, hasLength(3)); + expect(result[0]!.localidade, 'São Paulo'); + expect(result[1], isNull); + expect(result[2]!.uf, 'RJ'); + expect(result[2]!.logradouro, 'Avenida Rio Branco'); + }); + + test('a batch never carries a coordinate', () async { + final ceps = ['01001000']; + server.routes.respond( + batchPath(ceps), + Reply( + 200, + csv([ + '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,' + '3550308,1004,11,7107', + ]), + ), + ); + + final result = await client.lookupMany(ceps); + expect(result.single, isA
()); + expect(result.single!.geo, isNull); + }); + + test('a field containing a comma is parsed', () async { + final ceps = ['01001000']; + server.routes.respond( + batchPath(ceps), + Reply( + 200, + csv([ + '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,' + 'Sudeste,1,,11,1', + ]), + ), + ); + + final result = await client.lookupMany(ceps); + expect(result.single!.logradouro, 'Rua Um, Dois'); + }); + + test('an empty input makes no request', () async { + expect(await client.lookupMany([]), isEmpty); + expect(server.routes.calls, isEmpty); + }); + + test('chunks above the API limit', () async { + final ceps = List.generate( + 501, + (int i) => i.toString().padLeft(8, '0'), + ); + server.routes.fallback = (String path) { + final requested = + Uri.parse('http://x$path').queryParameters['ceps'] ?? ''; + final rows = requested + .split(',') + .map((String cep) => '$cep,,,,,Cidade,SP,São Paulo,Sudeste,1,,11,1') + .toList(); + return Reply(200, csv(rows)); + }; + + final result = await client.lookupMany(ceps); + expect(server.routes.calls, hasLength(2)); + expect(result, hasLength(501)); + expect(result[500]!.cep, '00000500'); + }); + + test('a truncated response pads rather than shifts', () async { + final ceps = ['01001000', '20040020']; + server.routes.respond( + batchPath(ceps), + Reply( + 200, + csv(['01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1']), + ), + ); + + final result = await client.lookupMany(ceps); + expect(result, hasLength(2)); + expect(result[0]!.localidade, 'São Paulo'); + expect(result[1], isNull); + }); + + test('a malformed CEP is null, not an error', () async { + final ceps = ['01001000', 'nope']; + server.routes.respond( + batchPath(ceps), + Reply( + 200, + csv([ + '01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1', + 'nope,,,,,,,,,,,,', + ]), + ), + ); + + final result = await client.lookupMany(ceps); + expect(result[0], isNotNull); + expect(result[1], isNull); + }); + }); + + group('health', () { + test('reads the report', () async { + server.routes.respond( + '/health', + Reply( + 200, + jsonEncode({ + 'status': 'ok', + 'dne': '26071', + 'ceps': 1596168, + 'geo': 894489, + 'base_gerada_em': '2026-07-24T03:48:28Z', + }), + ), + ); + + final report = await client.health(); + expect(report.ok, isTrue); + expect(report.dne, '26071'); + expect(report.ceps, 1596168); + expect(report.geo, 894489); + expect(report.baseGeradaEm, '2026-07-24T03:48:28Z'); + }); + + test('a degraded API does not throw and is not retried', () async { + server.routes.respond( + '/health', + Reply( + 503, + jsonEncode({ + 'status': 'degradado', + 'dne': '', + 'ceps': 0, + 'geo': 0, + }), + ), + ); + + final report = await client.health(); + expect(report.ok, isFalse); + expect(report.status, 'degradado'); + expect(server.routes.count('/health'), 1); + }); + }); + + group('configuration', () { + test('a trailing slash is not duplicated', () { + final trailing = Client(baseUrl: 'http://example.test/'); + addTearDown(trailing.close); + expect(trailing.baseUrl, 'http://example.test'); + }); + + test('rejects impossible settings', () { + expect(() => Client(maxAttempts: 0), throwsArgumentError); + expect(() => Client(timeout: Duration.zero), throwsArgumentError); + expect( + () => Client(totalTimeout: const Duration(seconds: -1)), + throwsArgumentError, + ); + }); + + test('the default User-Agent identifies the library', () { + final plain = Client(); + addTearDown(plain.close); + expect(plain.userAgent, startsWith('cepis-dart/')); + }); + }); + + group('parseCsv', () { + test('handles quotes, escapes and CRLF', () { + expect( + parseCsv('a,b\r\n"x,1","he said ""hi"""\r\n'), >[ + {'a': 'x,1', 'b': 'he said "hi"'}, + ]); + }); + + test('fills columns a short row does not have', () { + expect(parseCsv('a,b,c\n1\n'), >[ + {'a': '1', 'b': '', 'c': ''}, + ]); + }); + + test('an empty input yields no records', () { + expect(parseCsv(''), isEmpty); + }); + }); + + group('models', () { + test('two identical addresses are equal', () { + final one = Address.fromJson(Map.of(seAddress)); + final two = Address.fromJson({ + ...seAddress, + 'campo_do_futuro': 'value', + }); + expect(one, two); + expect(one.hashCode, two.hashCode); + }); + }); +} diff --git a/test/server.dart b/test/server.dart new file mode 100644 index 0000000..b8f28de --- /dev/null +++ b/test/server.dart @@ -0,0 +1,126 @@ +/// A throwaway HTTP server on a random loopback port. +/// +/// The suite points a real `Client` at a real socket: no test touches the +/// network, and no test stubs 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 `baseUrl` works — if it did not, none of this +/// would run. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// What the fake server should answer for one request. +class Reply { + /// Creates an answer with [status], [body] and any extra [headers]. + const Reply(this.status, this.body, + {this.headers = const {}}); + + /// HTTP status to answer with. + final int status; + + /// Body to write, encoded as UTF-8. + final String body; + + /// Extra response headers, such as `Retry-After`. + final Map headers; +} + +/// Per-test programmable routes, plus a record of what was asked. +class Router { + final Map> _routes = >{}; + + /// Every path requested so far, in order, query string included. + final List calls = []; + + /// The `User-Agent` of every request so far, in order. + final List userAgents = []; + + /// Answers any path with no registered route. + Reply Function(String path)? fallback; + + /// Registers the single answer for [path]. + void respond(String path, Reply reply) { + _routes[path] = [reply]; + } + + /// Registers a sequence of answers for [path], one per request. + /// + /// The last entry repeats once the sequence runs out, which is how retrying + /// is tested. + void respondInSequence(String path, List replies) { + _routes[path] = List.of(replies); + } + + /// Records the call and returns the answer registered for [path]. + Reply next(String path) { + calls.add(path); + final replies = _routes[path]; + if (replies == null) { + final handler = fallback; + if (handler != null) { + return handler(path); + } + return Reply( + 404, jsonEncode({'erro': 'cep_nao_encontrado'})); + } + return replies.length > 1 ? replies.removeAt(0) : replies.first; + } + + /// How many requests so far began with [prefix]. + int count(String prefix) => + calls.where((String call) => call.startsWith(prefix)).length; +} + +/// A running fake API, with its router. +class FakeServer { + FakeServer._(this._server, this.routes); + + final HttpServer _server; + + /// The routes this server answers from. + final Router routes; + + /// Starts a fake API on `127.0.0.1` and a port the kernel picks. + static Future start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = FakeServer._(server, Router()); + unawaited(fake._serve()); + return fake; + } + + /// Where to point a client's `baseUrl`. + String get url => 'http://127.0.0.1:${_server.port}'; + + /// Stops the server and drops any open connection. + Future stop() => _server.close(force: true); + + Future _serve() async { + await for (final HttpRequest request in _server) { + await request.drain(); + routes.userAgents.add( + request.headers.value(HttpHeaders.userAgentHeader) ?? '', + ); + + final uri = request.uri; + final path = uri.query.isEmpty ? uri.path : '${uri.path}?${uri.query}'; + final reply = routes.next(path); + + final response = request.response + ..statusCode = reply.status + ..headers.contentType = ContentType( + 'application', + 'json', + charset: 'utf-8', + ); + for (final MapEntry header in reply.headers.entries) { + response.headers.set(header.key, header.value); + } + response.add(utf8.encode(reply.body)); + await response.close(); + } + } +} -- cgit v1.3