summaryrefslogtreecommitdiffstats
path: root/t
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 14:40:05 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 14:40:05 -0300
commit0bc578d6d334c7f1ccaf329cd61bb8bce76b7f59 (patch)
tree86f73108f0432fd8330edcaab8b8ae7018322dee /t
downloadperl-0bc578d6d334c7f1ccaf329cd61bb8bce76b7f59.tar.gz
Initial importv0.1.0
Diffstat (limited to 't')
-rw-r--r--t/00-load.t42
-rw-r--r--t/01-normalize.t32
-rw-r--r--t/02-csv.t105
-rw-r--r--t/03-lookup.t187
-rw-r--r--t/04-retry.t147
-rw-r--r--t/05-batch.t146
-rw-r--r--t/06-health.t58
-rw-r--r--t/07-config.t63
-rw-r--r--t/08-tls.t58
-rw-r--r--t/lib/TestServer.pm298
10 files changed, 1136 insertions, 0 deletions
diff --git a/t/00-load.t b/t/00-load.t
new file mode 100644
index 0000000..73c8063
--- /dev/null
+++ b/t/00-load.t
@@ -0,0 +1,42 @@
+use strict;
+use warnings;
+
+use Test::More;
+
+my @modules = qw(
+ Cepis
+ Cepis::Address
+ Cepis::CSV
+ Cepis::Client
+ Cepis::Error
+ Cepis::Geo
+ Cepis::Health
+);
+
+use_ok($_) for @modules;
+
+is( $Cepis::VERSION, '0.1.0', 'Cepis is at version 0.1.0' );
+
+for my $module (@modules) {
+ no strict 'refs';
+ is( ${"${module}::VERSION"}, $Cepis::VERSION, "$module carries the same version" );
+}
+
+for my $class (
+ qw(
+ Cepis::Error::InvalidCEP
+ Cepis::Error::CEPNotFound
+ Cepis::Error::Network
+ Cepis::Error::ServerError
+ Cepis::Error::RateLimited
+ Cepis::Error::InvalidResponse
+ )
+ )
+{
+ ok( $class->isa('Cepis::Error'), "$class is a Cepis::Error" );
+}
+
+can_ok( 'Cepis', qw(lookup lookup_many health normalize_cep default_client) );
+can_ok( 'Cepis::Client', qw(new lookup lookup_many health) );
+
+done_testing();
diff --git a/t/01-normalize.t b/t/01-normalize.t
new file mode 100644
index 0000000..bf8a85f
--- /dev/null
+++ b/t/01-normalize.t
@@ -0,0 +1,32 @@
+use strict;
+use warnings;
+
+use Test::More;
+
+use Cepis;
+
+for my $value ( '01001000', '01001-000', ' 01001-000 ', '01001.000' ) {
+ is( Cepis::normalize_cep($value), '01001000', "'$value' normalises" );
+}
+
+for my $value ( '', '123', '0100100a', '010010000', 'abcdefgh', '0100 1000x' ) {
+ my $ok = eval { Cepis::normalize_cep($value); 1 };
+ ok( !$ok, "'$value' is rejected" );
+ isa_ok( $@, 'Cepis::Error::InvalidCEP' );
+}
+
+for my $value ( undef, [], {}, \'x' ) {
+ my $ok = eval { Cepis::normalize_cep($value); 1 };
+ ok( !$ok, 'a non-string is rejected' );
+ isa_ok( $@, 'Cepis::Error::InvalidCEP' );
+}
+
+my $ok = eval { Cepis::normalize_cep('123'); 1 };
+ok( !$ok, 'malformed CEP throws' );
+ok( ref $@ && $@->isa('Cepis::Error'), 'one isa test catches everything' );
+like( "$@", qr/8 digits/, 'the error stringifies to its message' );
+
+is( Cepis::Client::normalize_cep('01001-000'),
+ '01001000', 'the function is also reachable on Cepis::Client' );
+
+done_testing();
diff --git a/t/02-csv.t b/t/02-csv.t
new file mode 100644
index 0000000..8850620
--- /dev/null
+++ b/t/02-csv.t
@@ -0,0 +1,105 @@
+use strict;
+use warnings;
+use utf8;
+
+use Test::More;
+
+use Cepis::CSV;
+
+binmode Test::More->builder->output, ':encoding(UTF-8)';
+binmode Test::More->builder->failure_output, ':encoding(UTF-8)';
+
+is_deeply( Cepis::CSV::parse_rows(''), [], 'empty input yields no rows' );
+
+is_deeply(
+ Cepis::CSV::parse_rows("a,b,c\n1,2,3\n"),
+ [ [qw(a b c)], [qw(1 2 3)] ],
+ 'plain rows'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows("a,b\r\n1,2\r\n"),
+ [ [qw(a b)], [qw(1 2)] ],
+ 'CRLF line endings'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows("a,b\n1,2"),
+ [ [qw(a b)], [qw(1 2)] ],
+ 'a missing final newline still closes the row'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows(qq{a,b\n"um, dois",3\n}),
+ [ [qw(a b)], [ 'um, dois', '3' ] ],
+ 'a quoted field may hold a comma'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows(qq{a\n"he said ""hi"""\n}),
+ [ ['a'], ['he said "hi"'] ],
+ 'a doubled quote is an escaped quote'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows(qq{a,b\n"line\none",2\n}),
+ [ [qw(a b)], [ "line\none", '2' ] ],
+ 'a quoted field may hold a newline'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows(qq{a,b,c\n,,\n}),
+ [ [qw(a b c)], [ '', '', '' ] ],
+ 'empty fields survive'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows(qq{a,b\n"",""\n}),
+ [ [qw(a b)], [ '', '' ] ],
+ 'explicitly quoted empty fields survive'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows("\x{FEFF}a,b\n1,2\n"),
+ [ [qw(a b)], [qw(1 2)] ],
+ 'a byte order mark is stripped'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows("a,b\n1,2\n\n\n"),
+ [ [qw(a b)], [qw(1 2)] ],
+ 'blank lines are skipped'
+);
+
+is_deeply(
+ Cepis::CSV::parse_rows("cep,localidade\n01001000,São Paulo\n"),
+ [ [qw(cep localidade)], [ '01001000', 'São Paulo' ] ],
+ 'non-ASCII characters survive'
+);
+
+my ( $header, $records )
+ = Cepis::CSV::parse_hashes("cep,localidade,uf\n01001000,São Paulo,SP\n99999999,,\n");
+
+is_deeply( $header, [qw(cep localidade uf)], 'the header row comes back' );
+is_deeply(
+ $records,
+ [ { cep => '01001000', localidade => 'São Paulo', uf => 'SP' },
+ { cep => '99999999', localidade => '', uf => '' },
+ ],
+ 'records are keyed by column name'
+);
+
+( $header, $records ) = Cepis::CSV::parse_hashes("a,b,c\n1\n");
+is_deeply(
+ $records,
+ [ { a => '1', b => '', c => '' } ],
+ 'a short row is filled with empty strings'
+);
+
+( $header, $records ) = Cepis::CSV::parse_hashes("a\n1,2,3\n");
+is_deeply( $records, [ { a => '1' } ], 'fields beyond the header are dropped' );
+
+( $header, $records ) = Cepis::CSV::parse_hashes('');
+is_deeply( [ $header, $records ], [ [], [] ], 'empty input yields nothing' );
+
+done_testing();
diff --git a/t/03-lookup.t b/t/03-lookup.t
new file mode 100644
index 0000000..5fb3937
--- /dev/null
+++ b/t/03-lookup.t
@@ -0,0 +1,187 @@
+use strict;
+use warnings;
+use utf8;
+
+use FindBin ();
+use lib "$FindBin::Bin/lib";
+
+use Test::More;
+use TestServer qw(se_address json new_client);
+
+use Cepis;
+
+{
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 200, json( se_address() ), {} ] } );
+ my $client = new_client($server);
+
+ my $address = $client->lookup('01001-000');
+ is( $address->localidade, 'São Paulo', 'localidade' );
+ is( $address->uf, 'SP', 'uf' );
+ is( $address->logradouro, 'Praça da Sé', 'logradouro' );
+ is( $address->cep, '01001000', 'cep comes back unmasked' );
+ is( $address->formatted_cep, '01001-000', 'formatted_cep applies the mask' );
+ is( $address->unidade, '', 'an absent field is an empty string' );
+ is( $address->geo, undef, 'no geo block means no geo' );
+ is( $server->count('/01001000'), 1, 'exactly one request was made' );
+ $server->stop;
+}
+
+{
+ my $body = { %{ se_address() },
+ geo => { lat => -23.567202, lon => -46.649659, raio_m => 1084, precisao => 'logradouro' }
+ };
+ my $server = TestServer->new( routes => { '/01310100' => [ 200, json($body), {} ] } );
+ my $client = new_client($server);
+
+ my $geo = $client->lookup('01310100')->geo;
+ isa_ok( $geo, 'Cepis::Geo' );
+ cmp_ok( abs( $geo->lat + 23.567202 ), '<', 1e-9, 'lat' );
+ cmp_ok( abs( $geo->lon + 46.649659 ), '<', 1e-9, 'lon' );
+ is( $geo->raio_m, 1084, 'raio_m' );
+ is( $geo->precisao, 'logradouro', 'precisao' );
+ ok( $geo->is_precise, 'a street coordinate is precise' );
+ $server->stop;
+}
+
+{
+ my $body = { %{ se_address() },
+ geo => { lat => -23.5, lon => -46.6, raio_m => 22000, precisao => 'localidade' } };
+ my $server = TestServer->new( routes => { '/01001000' => [ 200, json($body), {} ] } );
+ my $client = new_client($server);
+
+ my $geo = $client->lookup('01001000')->geo;
+ ok( !$geo->is_precise, 'a municipality centroid is not precise' );
+ is( $geo->raio_m, 22000, 'the radius gives the error away' );
+ $server->stop;
+}
+
+{
+ my $body = { %{ se_address() }, geo => { lat => 'not a number' } };
+ my $server = TestServer->new( routes => { '/01001000' => [ 200, json($body), {} ] } );
+ my $client = new_client($server);
+
+ my $address = $client->lookup('01001000');
+ is( $address->geo, undef, 'a malformed geo block is dropped' );
+ is( $address->localidade, 'São Paulo', 'and the address survives it' );
+ $server->stop;
+}
+
+{
+ my $body = { %{ se_address() }, campo_do_futuro => 'value' };
+ my $server = TestServer->new( routes => { '/01001000' => [ 200, json($body), {} ] } );
+ my $client = new_client($server);
+
+ my $address = $client->lookup('01001000');
+ is( $address->field('campo_do_futuro'), 'value', 'an unknown field is preserved' );
+ is( $address->raw->{campo_do_futuro}, 'value', 'and is reachable through raw' );
+ is( $address->localidade, 'São Paulo', 'and does not break construction' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => {
+ '/99999999' => [
+ 404,
+ json( { erro => 'cep_nao_encontrado', mensagem => 'CEP não existe na base do DNE' } ),
+ {}
+ ]
+ }
+ );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup('99999999'); 1 };
+ ok( !$ok, 'a missing CEP throws' );
+ isa_ok( $@, 'Cepis::Error::CEPNotFound' );
+ like( "$@", qr/99999999/, 'the message names the CEP' );
+ is( $server->count('/99999999'), 1, 'a 404 is not retried' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 200, json( se_address() ), {} ] } );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup('123'); 1 };
+ ok( !$ok, 'a malformed CEP throws' );
+ isa_ok( $@, 'Cepis::Error::InvalidCEP' );
+ is( scalar( $server->calls ), 0, 'and never leaves the machine' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => {
+ '/00000000' => [
+ 400,
+ json( { erro => 'cep_invalido', mensagem => 'rejected by the API' } ), {}
+ ]
+ }
+ );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup('00000000'); 1 };
+ ok( !$ok, 'a 400 throws' );
+ isa_ok( $@, 'Cepis::Error::InvalidCEP' );
+ like( "$@", qr/rejected by the API/, "the API's own message comes through" );
+ is( $server->count('/00000000'), 1, 'a 400 is not retried' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => {
+ '/01001000' => [ 200, '<html>captive portal</html>', {} ],
+ '/01001001' => [ 200, '[1,2,3]', {} ],
+ }
+ );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'a body that is not JSON throws' );
+ isa_ok( $@, 'Cepis::Error::InvalidResponse' );
+ like( "$@", qr/captive portal/, 'the message quotes what came back' );
+
+ $ok = eval { $client->lookup('01001001'); 1 };
+ ok( !$ok, 'a JSON array is not an address' );
+ isa_ok( $@, 'Cepis::Error::InvalidResponse' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ handler => sub {
+ my ( $path, $seen, $headers ) = @_;
+ return [ 200, json( { %{ se_address() }, unidade => $headers->{'user-agent'} } ), {} ];
+ }
+ );
+ my $client = new_client($server);
+
+ like(
+ $client->lookup('01001000')->unidade,
+ qr{\Acepis-perl/0\.1\.0 \(\+https://cep\.is\)\z},
+ 'the request identifies the library'
+ );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ handler => sub {
+ my ( $path, $seen, $headers ) = @_;
+ return [ 200, json( { %{ se_address() }, unidade => $headers->{accept} } ), {} ];
+ }
+ );
+ my $client = new_client($server);
+
+ like(
+ $client->lookup('01001000')->unidade,
+ qr{application/json},
+ 'and sends an Accept header'
+ );
+ $server->stop;
+}
+
+done_testing();
diff --git a/t/04-retry.t b/t/04-retry.t
new file mode 100644
index 0000000..d304b5a
--- /dev/null
+++ b/t/04-retry.t
@@ -0,0 +1,147 @@
+use strict;
+use warnings;
+use utf8;
+
+use FindBin ();
+use lib "$FindBin::Bin/lib";
+
+use Test::More;
+use TestServer qw(se_address json new_client);
+
+use Cepis;
+use Time::HiRes ();
+
+{
+ my $server = TestServer->new(
+ routes => {
+ '/01001000' => [
+ [ 500, json( { erro => 'interno' } ), {} ],
+ [ 200, json( se_address() ), {} ],
+ ]
+ }
+ );
+ my $client = new_client($server);
+
+ is( $client->lookup('01001000')->localidade, 'São Paulo', 'a retry recovers' );
+ is( $server->count('/01001000'), 2, 'it took two attempts' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 503, json( { erro => 'interno' } ), {} ] } );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'exhausting the attempts throws' );
+ isa_ok( $@, 'Cepis::Error::ServerError' );
+ is( $@->status, 503, 'the status of the last attempt is exposed' );
+ is( $server->count('/01001000'), 3, 'three attempts by default' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new( routes => { '/01001000' => [ 500, '{}', {} ] } );
+ my $client = new_client( $server, max_attempts => 1 );
+
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'max_attempts of 1 still throws' );
+ is( $server->count('/01001000'), 1, 'and disables retrying' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 429, '{}', { 'Retry-After' => '7' } ] } );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'a 429 throws' );
+ isa_ok( $@, 'Cepis::Error::RateLimited' );
+ cmp_ok( abs( $@->retry_after - 7 ), '<', 1e-9, 'Retry-After in seconds is read' );
+ $server->stop;
+}
+
+{
+ my @day = qw(Sun Mon Tue Wed Thu Fri Sat);
+ my @month = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
+ my @t = gmtime( time + 4 );
+ my $date = sprintf '%s, %02d %s %04d %02d:%02d:%02d GMT', $day[ $t[6] ], $t[3],
+ $month[ $t[4] ], $t[5] + 1900, $t[2], $t[1], $t[0];
+
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 429, '{}', { 'Retry-After' => $date } ] } );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'a 429 with an HTTP date throws' );
+ isa_ok( $@, 'Cepis::Error::RateLimited' );
+ cmp_ok( $@->retry_after, '>', 0, 'the date form is parsed too' );
+ cmp_ok( $@->retry_after, '<=', 5, 'and lands where it should' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 429, '{}', { 'Retry-After' => '3600' } ] } );
+ my $client = new_client($server);
+
+ my $started = Time::HiRes::time();
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'an absurd Retry-After still throws' );
+ cmp_ok( Time::HiRes::time() - $started, '<', 5, 'and is capped by backoff_max' );
+ $server->stop;
+}
+
+{
+ my $client = Cepis::Client->new(
+ base_url => 'http://127.0.0.1:1',
+ max_attempts => 2,
+ backoff_base => 0.001,
+ backoff_max => 0.01,
+ timeout => 1.0,
+ );
+
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'nothing listening throws' );
+ isa_ok( $@, 'Cepis::Error::Network' );
+ like( "$@", qr/127\.0\.0\.1:1/, 'the message names the URL' );
+}
+
+{
+ my $server = TestServer->new( routes => { '/01001000' => [ 500, '{}', {} ] } );
+ my $client = new_client(
+ $server,
+ max_attempts => 50,
+ backoff_base => 0.05,
+ backoff_max => 0.05,
+ total_timeout => 0.3,
+ );
+
+ my $started = Time::HiRes::time();
+ my $ok = eval { $client->lookup('01001000'); 1 };
+ ok( !$ok, 'the call still throws' );
+ isa_ok( $@, 'Cepis::Error::ServerError' );
+ cmp_ok( Time::HiRes::time() - $started, '<', 3, 'total_timeout bounds the whole call' );
+ cmp_ok( $server->count('/01001000'), '<', 50, 'and cuts the attempts short' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => {
+ '/01001000' => [
+ [ 502, '{}', {} ],
+ [ 504, '{}', {} ],
+ [ 200, json( se_address() ), {} ],
+ ]
+ }
+ );
+ my $client = new_client($server);
+
+ is( $client->lookup('01001000')->uf, 'SP', '502 and 504 are retried too' );
+ is( $server->count('/01001000'), 3, 'across three attempts' );
+ $server->stop;
+}
+
+done_testing();
diff --git a/t/05-batch.t b/t/05-batch.t
new file mode 100644
index 0000000..3ae51d0
--- /dev/null
+++ b/t/05-batch.t
@@ -0,0 +1,146 @@
+use strict;
+use warnings;
+use utf8;
+
+use FindBin ();
+use lib "$FindBin::Bin/lib";
+
+use Test::More;
+use TestServer qw(csv_body new_client);
+
+use Cepis;
+
+{
+ my $body = csv_body(
+ '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',
+ );
+ my $server = TestServer->new(
+ routes => { '/csv?ceps=01001000%2C99999999%2C20040020' => [ 200, $body, {} ] } );
+ my $client = new_client($server);
+
+ my $result = $client->lookup_many( [qw(01001000 99999999 20040020)] );
+ is( scalar @$result, 3, 'the result is as long as the input' );
+ is( $result->[0]->localidade, 'São Paulo', 'the first CEP resolved' );
+ is( $result->[1], undef, 'a missing CEP is undef at its position' );
+ is( $result->[2]->uf, 'RJ', 'and the third is not shifted up' );
+ $server->stop;
+}
+
+{
+ my $body = csv_body(
+ '01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107');
+ my $server
+ = TestServer->new( routes => { '/csv?ceps=01001000' => [ 200, $body, {} ] } );
+ my $client = new_client($server);
+
+ my $address = $client->lookup_many( ['01001000'] )->[0];
+ is( $address->localidade, 'São Paulo', 'the batch resolved it' );
+ is( $address->geo, undef, 'but a batch never carries a coordinate' );
+ $server->stop;
+}
+
+{
+ my $body = csv_body(
+ '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1');
+ my $server
+ = TestServer->new( routes => { '/csv?ceps=01001000' => [ 200, $body, {} ] } );
+ my $client = new_client($server);
+
+ is( $client->lookup_many( ['01001000'] )->[0]->logradouro,
+ 'Rua Um, Dois', 'a quoted field holding a comma is parsed' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new;
+ my $client = new_client($server);
+
+ is_deeply( $client->lookup_many( [] ), [], 'an empty list yields an empty list' );
+ is( scalar( $server->calls ), 0, 'and makes no request' );
+ $server->stop;
+}
+
+{
+ my @ceps = map { sprintf '%08d', $_ } 1 .. 501;
+ my @rows = map {"$_,,,,,X,SP,São Paulo,Sudeste,1,,11,1"} @ceps;
+
+ my $server = TestServer->new(
+ handler => sub {
+ my ( $path, $seen ) = @_;
+ my @slice = $seen == 1 ? @rows[ 0 .. 499 ] : @rows[ 500 .. 500 ];
+ return [ 200, csv_body(@slice), {} ];
+ }
+ );
+ my $client = new_client($server);
+
+ my $result = $client->lookup_many( \@ceps );
+ is( scalar( $server->calls ), 2, '501 CEPs are split into two requests' );
+ is( scalar @$result, 501, 'and the result is still 501 long' );
+ is( $result->[500]->localidade, 'X', 'the last one is present' );
+ $server->stop;
+}
+
+{
+ my $body = csv_body('01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1');
+ my $server = TestServer->new(
+ routes => { '/csv?ceps=01001000%2C20040020' => [ 200, $body, {} ] } );
+ my $client = new_client($server);
+
+ my $result = $client->lookup_many( [qw(01001000 20040020)] );
+ is( scalar @$result, 2, 'a truncated response is padded, not returned short' );
+ is( $result->[1], undef, 'the missing tail is undef' );
+ $server->stop;
+}
+
+{
+ my $body = csv_body(
+ '01001000,,,,,X,SP,São Paulo,Sudeste,1,,11,1',
+ '01001001,,,,,Y,SP,São Paulo,Sudeste,1,,11,1',
+ '01001002,,,,,Z,SP,São Paulo,Sudeste,1,,11,1',
+ '01001003,,,,,W,SP,São Paulo,Sudeste,1,,11,1',
+ );
+ my $server = TestServer->new(
+ routes => { '/csv?ceps=01001000%2C01001001' => [ 200, $body, {} ] } );
+ my $client = new_client($server);
+
+ my $result = $client->lookup_many( [qw(01001000 01001001)] );
+ is( scalar @$result, 2, 'an over-long response is truncated to the input length' );
+ $server->stop;
+}
+
+{
+ my $body = csv_body( '123,,,,,,,,,,,,', '01001000,,,,,X,SP,São Paulo,Sudeste,1,,11,1' );
+ my $server = TestServer->new(
+ routes => { '/csv?ceps=123%2C01001000' => [ 200, $body, {} ] } );
+ my $client = new_client($server);
+
+ my $result = $client->lookup_many( [qw(123 01001000)] );
+ is( $result->[0], undef, 'a malformed CEP is undef rather than an exception' );
+ is( $result->[1]->localidade, 'X', 'and does not cost the rest of the batch' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new(
+ routes => { '/csv?ceps=01001000' => [ 200, '<html>captive portal</html>', {} ] } );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup_many( ['01001000'] ); 1 };
+ ok( !$ok, 'a body that is not the expected CSV throws' );
+ isa_ok( $@, 'Cepis::Error::InvalidResponse' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new;
+ my $client = new_client($server);
+
+ my $ok = eval { $client->lookup_many('01001000'); 1 };
+ ok( !$ok, 'lookup_many wants an array reference' );
+ $server->stop;
+}
+
+done_testing();
diff --git a/t/06-health.t b/t/06-health.t
new file mode 100644
index 0000000..fca2820
--- /dev/null
+++ b/t/06-health.t
@@ -0,0 +1,58 @@
+use strict;
+use warnings;
+
+use FindBin ();
+use lib "$FindBin::Bin/lib";
+
+use Test::More;
+use TestServer qw(json new_client);
+
+use Cepis;
+
+{
+ my $body = json(
+ { status => 'ok',
+ dne => '26071',
+ ceps => 1596168,
+ geo => 894489,
+ base_gerada_em => '2026-07-24T03:48:28Z',
+ }
+ );
+ my $server = TestServer->new( routes => { '/health' => [ 200, $body, {} ] } );
+ my $client = new_client($server);
+
+ my $health = $client->health;
+ isa_ok( $health, 'Cepis::Health' );
+ ok( $health->ok, 'the API reports itself healthy' );
+ is( $health->dne, '26071', 'dne identifies the database' );
+ is( $health->ceps, 1596168, 'ceps' );
+ is( $health->geo, 894489, 'geo is a count here, not an object' );
+ is( $health->base_gerada_em, '2026-07-24T03:48:28Z', 'base_gerada_em' );
+ $server->stop;
+}
+
+{
+ my $body = json(
+ { status => 'degradado', dne => '', ceps => 0, geo => 0, base_gerada_em => '' } );
+ my $server = TestServer->new( routes => { '/health' => [ 503, $body, {} ] } );
+ my $client = new_client($server);
+
+ my $health = $client->health;
+ ok( !$health->ok, 'a degraded API does not throw' );
+ is( $health->status, 'degradado', 'the diagnosis arrives in status' );
+ is( $server->count('/health'), 1, 'and a 503 on /health is not retried' );
+ $server->stop;
+}
+
+{
+ my $server = TestServer->new( routes => { '/health' => [ 500, '{}', {} ] } );
+ my $client = new_client($server);
+
+ my $ok = eval { $client->health; 1 };
+ ok( !$ok, 'a 500 on /health still throws' );
+ isa_ok( $@, 'Cepis::Error::ServerError' );
+ is( $server->count('/health'), 3, 'after retrying' );
+ $server->stop;
+}
+
+done_testing();
diff --git a/t/07-config.t b/t/07-config.t
new file mode 100644
index 0000000..5c6f19b
--- /dev/null
+++ b/t/07-config.t
@@ -0,0 +1,63 @@
+use strict;
+use warnings;
+
+use FindBin ();
+use lib "$FindBin::Bin/lib";
+
+use Test::More;
+use TestServer qw(se_address json new_client);
+
+use Cepis;
+
+is( Cepis::Client->new( base_url => 'http://x/' )->base_url,
+ 'http://x', 'a trailing slash is stripped' );
+is( Cepis::Client->new( base_url => 'http://x///' )->base_url,
+ 'http://x', 'and so are several' );
+is( Cepis::Client->new->base_url, 'https://cep.is', 'the default base_url' );
+is( Cepis::Client::DEFAULT_URL(), 'https://cep.is', 'DEFAULT_URL agrees' );
+is( Cepis::Client::MAX_BATCH(), 500, 'MAX_BATCH is the API limit' );
+
+for my $bad (
+ { max_attempts => 0 },
+ { max_attempts => -1 },
+ { timeout => 0 },
+ { timeout => -1 },
+ { backoff_max => -1 },
+ )
+{
+ my ($name) = keys %$bad;
+ my $ok = eval { Cepis::Client->new(%$bad); 1 };
+ ok( !$ok, "$name = $bad->{$name} is rejected" );
+}
+
+my $client = Cepis::Client->new;
+like( $client->user_agent, qr{\Acepis-perl/0\.1\.0 }, 'the User-Agent names the library' );
+like( $client->user_agent, qr{https://cep\.is}, 'and where to find it' );
+
+is( Cepis::Client->new( user_agent => 'myapp/1.0' )->user_agent,
+ 'myapp/1.0', 'the User-Agent is overridable' );
+
+is( $client->timeout, 10, 'default timeout' );
+is( $client->max_attempts, 3, 'default max_attempts' );
+is( $client->backoff_base, 0.5, 'default backoff_base' );
+is( $client->backoff_max, 8, 'default backoff_max' );
+is( $client->total_timeout, undef, 'no total_timeout by default' );
+
+isa_ok( Cepis::default_client(), 'Cepis::Client' );
+is( Cepis::default_client(), Cepis::default_client(), 'the default client is built once' );
+
+{
+ my $server = TestServer->new(
+ routes => {
+ '/01001000' => [ 200, json( se_address() ), {} ],
+ '/health' => [ 200, json( { status => 'ok' } ), {} ],
+ }
+ );
+ my $custom = new_client($server);
+
+ is( $custom->lookup('01001000')->uf, 'SP', 'base_url really redirects the client' );
+ ok( $custom->health->ok, 'for every endpoint' );
+ $server->stop;
+}
+
+done_testing();
diff --git a/t/08-tls.t b/t/08-tls.t
new file mode 100644
index 0000000..950fef3
--- /dev/null
+++ b/t/08-tls.t
@@ -0,0 +1,58 @@
+use strict;
+use warnings;
+
+use FindBin ();
+use lib "$FindBin::Bin/lib";
+
+use HTTP::Tiny;
+use Test::More;
+use TestServer qw(se_address json new_client);
+use Time::HiRes ();
+
+use Cepis;
+
+my ($expected) = HTTP::Tiny->can('can_ssl') ? HTTP::Tiny->can_ssl : (1);
+my ( $ok, $why ) = Cepis::Client::tls_available();
+
+is( !!$ok, !!$expected, 'tls_available agrees with HTTP::Tiny->can_ssl' );
+
+SKIP: {
+ skip 'this Perl has HTTPS support', 6 if $ok;
+
+ ok( defined $why && length $why, 'the reason is reported' );
+
+ my $client = Cepis::Client->new(
+ base_url => 'https://cep.is',
+ max_attempts => 3,
+ backoff_base => 30,
+ backoff_max => 30,
+ );
+
+ my $started = Time::HiRes::time();
+ my $done = eval { $client->lookup('01001000'); 1 };
+ my $err = $@;
+
+ ok( !$done, 'an https call without TLS support throws' );
+ isa_ok( $err, 'Cepis::Error::Network' );
+ like( "$err", qr/IO::Socket::SSL/, 'the message names IO::Socket::SSL' );
+ like( "$err", qr/Net::SSLeay/, 'and Net::SSLeay' );
+ cmp_ok( Time::HiRes::time() - $started, '<', 5,
+ 'and it fails at once rather than retrying a missing module' );
+}
+
+SKIP: {
+ skip 'this Perl has no HTTPS support', 1 unless $ok;
+ is( $why, undef, 'no reason is reported when HTTPS works' );
+}
+
+{
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 200, json( se_address() ), {} ] } );
+ my $client = new_client($server);
+
+ is( $client->lookup('01001000')->uf, 'SP',
+ 'an http base_url is never blocked by the TLS guard' );
+ $server->stop;
+}
+
+done_testing();
diff --git a/t/lib/TestServer.pm b/t/lib/TestServer.pm
new file mode 100644
index 0000000..5a1acda
--- /dev/null
+++ b/t/lib/TestServer.pm
@@ -0,0 +1,298 @@
+package TestServer;
+
+use strict;
+use warnings;
+
+use Encode ();
+use Exporter qw(import);
+use Fcntl qw(F_GETFL F_SETFL O_NONBLOCK);
+use IO::Socket::INET;
+use JSON::PP ();
+use POSIX ();
+use Test::More ();
+
+our @EXPORT_OK = qw(se_address csv_header csv_body json new_client);
+
+my %REASON = (
+ 200 => 'OK',
+ 400 => 'Bad Request',
+ 404 => 'Not Found',
+ 429 => 'Too Many Requests',
+ 500 => 'Internal Server Error',
+ 502 => 'Bad Gateway',
+ 503 => 'Service Unavailable',
+);
+
+my $ENCODER = JSON::PP->new->canonical;
+
+BEGIN {
+ my $builder = Test::More->builder;
+ binmode $builder->output, ':encoding(UTF-8)';
+ binmode $builder->failure_output, ':encoding(UTF-8)';
+ binmode $builder->todo_output, ':encoding(UTF-8)';
+}
+
+sub se_address {
+ return {
+ cep => '01001000',
+ logradouro => "Pra\x{e7}a da S\x{e9}",
+ complemento => "lado \x{ed}mpar",
+ unidade => '',
+ bairro => "S\x{e9}",
+ localidade => "S\x{e3}o Paulo",
+ uf => 'SP',
+ estado => "S\x{e3}o Paulo",
+ regiao => 'Sudeste',
+ ibge => '3550308',
+ gia => '1004',
+ ddd => '11',
+ siafi => '7107',
+ };
+}
+
+sub csv_header {
+ return 'cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,'
+ . 'regiao,ibge,gia,ddd,siafi';
+}
+
+sub csv_body {
+ my (@rows) = @_;
+ return join( "\n", csv_header(), @rows ) . "\n";
+}
+
+sub json {
+ my ($data) = @_;
+ return $ENCODER->encode($data);
+}
+
+sub new_client {
+ my ( $server, %args ) = @_;
+ require Cepis::Client;
+ return Cepis::Client->new(
+ base_url => $server->url,
+ timeout => 5.0,
+ backoff_base => 0.001,
+ backoff_max => 0.01,
+ %args,
+ );
+}
+
+sub new {
+ my ( $class, %args ) = @_;
+
+ my $listener = IO::Socket::INET->new(
+ LocalAddr => '127.0.0.1',
+ LocalPort => 0,
+ Proto => 'tcp',
+ Listen => 128,
+ ReuseAddr => 1,
+ ) or die "TestServer: cannot listen: $!";
+
+ my $port = $listener->sockport;
+
+ pipe( my $reader, my $writer ) or die "TestServer: pipe: $!";
+
+ my $pid = fork();
+ die "TestServer: fork: $!" unless defined $pid;
+
+ if ( $pid == 0 ) {
+ close $reader;
+ _child( $listener, $writer, $args{routes} || {}, $args{handler} );
+ POSIX::_exit(0);
+ }
+
+ close $writer;
+ close $listener;
+
+ my $flags = fcntl( $reader, F_GETFL, 0 );
+ fcntl( $reader, F_SETFL, $flags | O_NONBLOCK );
+
+ return bless {
+ pid => $pid,
+ port => $port,
+ reader => $reader,
+ buffer => '',
+ calls => [],
+ }, $class;
+}
+
+sub url { return 'http://127.0.0.1:' . $_[0]->{port} }
+
+sub calls {
+ my ($self) = @_;
+ $self->_drain;
+ return @{ $self->{calls} };
+}
+
+sub count {
+ my ( $self, $prefix ) = @_;
+ $self->_drain;
+ return scalar grep { index( $_, $prefix ) == 0 } @{ $self->{calls} };
+}
+
+sub stop {
+ my ($self) = @_;
+ return unless $self->{pid};
+ $self->_drain;
+ kill 'TERM', $self->{pid};
+ waitpid $self->{pid}, 0;
+ close $self->{reader};
+ delete $self->{pid};
+ return;
+}
+
+sub DESTROY {
+ my ($self) = @_;
+ local $@;
+ eval { $self->stop; 1 };
+ return;
+}
+
+sub _drain {
+ my ($self) = @_;
+ return unless $self->{reader};
+
+ while (1) {
+ my $chunk = '';
+ my $read = sysread( $self->{reader}, $chunk, 65536 );
+ last if !defined $read || $read == 0;
+ $self->{buffer} .= $chunk;
+ }
+ while ( $self->{buffer} =~ s/\A([^\n]*)\n// ) {
+ push @{ $self->{calls} }, $1;
+ }
+ return;
+}
+
+sub _child {
+ my ( $listener, $writer, $routes, $handler ) = @_;
+
+ local $SIG{PIPE} = 'IGNORE';
+ $writer->autoflush(1);
+
+ my %state = %$routes;
+ my $seen = 0;
+
+ while (1) {
+ my $socket = $listener->accept or next;
+ binmode $socket;
+ $socket->autoflush(1);
+
+ my $line = <$socket>;
+ if ( !defined $line ) {
+ close $socket;
+ next;
+ }
+ my $path = ( split /\s+/, $line )[1];
+ $path = '' unless defined $path;
+
+ my %request_headers;
+ while ( my $header = <$socket> ) {
+ last if $header eq "\r\n" || $header eq "\n";
+ $request_headers{ lc $1 } = $2 if $header =~ /\A([^:]+):\s*(.*?)\s*\z/s;
+ }
+
+ $seen++;
+ print {$writer} "$path\n";
+
+ my ( $status, $body, $headers )
+ = @{ _response_for( \%state, $handler, $path, $seen, \%request_headers ) };
+ $body = '' unless defined $body;
+ $headers = {} unless defined $headers;
+
+ my $bytes = Encode::encode( 'UTF-8', $body );
+ my $head = sprintf "HTTP/1.1 %d %s\r\n", $status,
+ ( $REASON{$status} || 'Unknown' );
+ $head .= "$_: $headers->{$_}\r\n" for sort keys %$headers;
+ $head .= "Content-Type: text/plain; charset=utf-8\r\n";
+ $head .= 'Content-Length: ' . length($bytes) . "\r\n";
+ $head .= "Connection: close\r\n\r\n";
+
+ print {$socket} $head;
+ print {$socket} $bytes;
+ close $socket;
+ }
+ return;
+}
+
+sub _response_for {
+ my ( $state, $handler, $path, $seen, $request_headers ) = @_;
+
+ return $handler->( $path, $seen, $request_headers ) if $handler;
+
+ my $response = $state->{$path};
+ return [ 404, json( { erro => 'cep_nao_encontrado' } ), {} ]
+ unless defined $response;
+
+ return $response unless ref $response->[0] eq 'ARRAY';
+ return @$response > 1 ? shift @$response : $response->[0];
+}
+
+1;
+
+__END__
+
+=encoding UTF-8
+
+=head1 NAME
+
+TestServer - a throwaway HTTP/1.1 server for the Cepis suite
+
+=head1 DESCRIPTION
+
+Brings up a real HTTP server on a random loopback port and points the client at
+it through C<base_url>. Nothing in the suite touches the network and nothing
+mocks L<HTTP::Tiny>: the code path exercised is the one that runs in production,
+request construction and error body reading included.
+
+Deliberately built on L<IO::Socket::INET> rather than on a web server from CPAN,
+because the suite must run against a stock Perl with nothing installed.
+
+The server runs in a forked child. Routes are set before the fork, so the child
+inherits them; each request the child serves is written down a pipe, which is
+what C<calls> and C<count> read back in the parent.
+
+=head1 METHODS
+
+=head2 new
+
+ my $server = TestServer->new(
+ routes => { '/01001000' => [ 200, $body, {} ] },
+ handler => sub { my ( $path, $n ) = @_; return [ 200, $body, {} ] },
+ );
+
+A route value is either a C<[ $status, $body, \%headers ]> triple or an arrayref
+of them, in which case each request consumes the next one and the last repeats
+once the list runs out — that is how retrying is tested. An unrouted path
+answers 404.
+
+C<handler> takes precedence over C<routes> and receives the path, the number of
+requests served so far, and a hashref of the request headers with lowercased
+names. The handler runs in the child, so the way to assert on a request header is
+to return it in the body and check it in the parent.
+
+Bodies are character strings; the child encodes them as UTF-8 and sets
+C<Content-Length> from the encoded bytes.
+
+=head2 url
+
+The base URL to hand to C<< Cepis::Client->new >>.
+
+=head2 calls
+
+Every path requested so far, in order.
+
+=head2 count
+
+How many requests so far began with the given prefix.
+
+=head2 stop
+
+Stops the child and reaps it. Called automatically on destruction.
+
+=head1 EXPORTABLE FUNCTIONS
+
+C<se_address> (the CEP fixture used across the suite), C<csv_header>,
+C<csv_body>, C<json> and C<new_client>.
+
+=cut