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); }); }); }