diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 14:40:05 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 14:40:05 -0300 |
| commit | 0bc578d6d334c7f1ccaf329cd61bb8bce76b7f59 (patch) | |
| tree | 86f73108f0432fd8330edcaab8b8ae7018322dee /t | |
| download | perl-0bc578d6d334c7f1ccaf329cd61bb8bce76b7f59.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 't')
| -rw-r--r-- | t/00-load.t | 42 | ||||
| -rw-r--r-- | t/01-normalize.t | 32 | ||||
| -rw-r--r-- | t/02-csv.t | 105 | ||||
| -rw-r--r-- | t/03-lookup.t | 187 | ||||
| -rw-r--r-- | t/04-retry.t | 147 | ||||
| -rw-r--r-- | t/05-batch.t | 146 | ||||
| -rw-r--r-- | t/06-health.t | 58 | ||||
| -rw-r--r-- | t/07-config.t | 63 | ||||
| -rw-r--r-- | t/08-tls.t | 58 | ||||
| -rw-r--r-- | t/lib/TestServer.pm | 298 |
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 |