summaryrefslogtreecommitdiffstats
path: root/test/cepis_test.dart
diff options
context:
space:
mode:
Diffstat (limited to 'test/cepis_test.dart')
-rw-r--r--test/cepis_test.dart632
1 files changed, 632 insertions, 0 deletions
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<String, dynamic> seAddress = <String, dynamic>{
+ '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<String> rows) => '$csvHeader\n${rows.join('\n')}\n';
+
+String batchPath(List<String> ceps) {
+ final query = Uri(
+ queryParameters: <String, String>{'ceps': ceps.join(',')},
+ ).query;
+ return '/csv?$query';
+}
+
+Map<String, dynamic> withGeo(Map<String, dynamic> geo) =>
+ <String, dynamic>{...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 <String>[
+ '01001000',
+ '01001-000',
+ ' 01001-000 ',
+ '01001.000',
+ ]) {
+ expect(normalizeCep(value), '01001000');
+ }
+ });
+
+ test('rejects a bad format', () {
+ for (final String value in <String>[
+ '',
+ '123',
+ '0100100a',
+ '010010000',
+ 'abcdefgh',
+ ]) {
+ expect(
+ () => normalizeCep(value),
+ throwsA(isA<InvalidCepException>()),
+ reason: value,
+ );
+ }
+ });
+
+ test('throws something a single handler can catch', () {
+ expect(() => normalizeCep('123'), throwsA(isA<CepisException>()));
+ });
+
+ 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(<String, dynamic>{
+ '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(<String, dynamic>{
+ '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(<String, dynamic>{'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(<String, dynamic>{
+ ...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(<String, String>{
+ 'erro': 'cep_nao_encontrado',
+ 'mensagem': 'CEP não existe na base do DNE',
+ }),
+ ),
+ );
+
+ await expectLater(
+ client.lookup('99999999'),
+ throwsA(
+ isA<CepNotFoundException>().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<InvalidCepException>()),
+ );
+ expect(server.routes.calls, isEmpty);
+ });
+
+ test('400 becomes InvalidCepException carrying the API message', () async {
+ server.routes.respond(
+ '/00000000',
+ Reply(
+ 400,
+ jsonEncode(<String, String>{
+ 'erro': 'cep_invalido',
+ 'mensagem': 'rejected by the API',
+ }),
+ ),
+ );
+
+ await expectLater(
+ client.lookup('00000000'),
+ throwsA(
+ isA<InvalidCepException>().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, '<html>portal</html>'));
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(isA<InvalidResponseException>()),
+ );
+ });
+
+ test('a JSON array is not an address', () async {
+ server.routes.respond('/01001000', const Reply(200, '[]'));
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(isA<InvalidResponseException>()),
+ );
+ });
+
+ 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>[
+ Reply(500, jsonEncode(<String, String>{'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(<String, String>{'erro': 'interno'})),
+ );
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(
+ isA<ServerException>().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<ServerException>()),
+ );
+ expect(server.routes.count('/01001000'), 1);
+ });
+
+ test('429 becomes RateLimitedException exposing Retry-After', () async {
+ server.routes.respond(
+ '/01001000',
+ const Reply(429, '{}', headers: <String, String>{'Retry-After': '7'}),
+ );
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(
+ isA<RateLimitedException>().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: <String, String>{'Retry-After': when}),
+ );
+
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(
+ isA<RateLimitedException>().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: <String, String>{'Retry-After': '3600'},
+ ),
+ );
+
+ final started = Stopwatch()..start();
+ await expectLater(
+ client.lookup('01001000'),
+ throwsA(isA<RateLimitedException>()),
+ );
+ 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<NetworkException>()),
+ );
+ });
+
+ 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<ServerException>()),
+ );
+ 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 = <String>['01001000', '99999999', '20040020'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '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 = <String>['01001000'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '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<Address>());
+ expect(result.single!.geo, isNull);
+ });
+
+ test('a field containing a comma is parsed', () async {
+ final ceps = <String>['01001000'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '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(<String>[]), isEmpty);
+ expect(server.routes.calls, isEmpty);
+ });
+
+ test('chunks above the API limit', () async {
+ final ceps = List<String>.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 = <String>['01001000', '20040020'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>['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 = <String>['01001000', 'nope'];
+ server.routes.respond(
+ batchPath(ceps),
+ Reply(
+ 200,
+ csv(<String>[
+ '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(<String, dynamic>{
+ '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(<String, dynamic>{
+ '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'), <Map<String, String>>[
+ <String, String>{'a': 'x,1', 'b': 'he said "hi"'},
+ ]);
+ });
+
+ test('fills columns a short row does not have', () {
+ expect(parseCsv('a,b,c\n1\n'), <Map<String, String>>[
+ <String, String>{'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<String, dynamic>.of(seAddress));
+ final two = Address.fromJson(<String, dynamic>{
+ ...seAddress,
+ 'campo_do_futuro': 'value',
+ });
+ expect(one, two);
+ expect(one.hashCode, two.hashCode);
+ });
+ });
+}