diff options
| -rw-r--r-- | MANIFEST | 2 | ||||
| -rw-r--r-- | README.md | 48 | ||||
| -rw-r--r-- | lib/Cepis.pm | 17 | ||||
| -rw-r--r-- | lib/Cepis/Address.pm | 2 | ||||
| -rw-r--r-- | lib/Cepis/CSV.pm | 2 | ||||
| -rw-r--r-- | lib/Cepis/Client.pm | 44 | ||||
| -rw-r--r-- | lib/Cepis/Error.pm | 37 | ||||
| -rw-r--r-- | lib/Cepis/Faixa.pm | 196 | ||||
| -rw-r--r-- | lib/Cepis/Geo.pm | 2 | ||||
| -rw-r--r-- | lib/Cepis/Health.pm | 2 | ||||
| -rw-r--r-- | t/00-load.t | 3 | ||||
| -rw-r--r-- | t/03-lookup.t | 2 | ||||
| -rw-r--r-- | t/07-config.t | 2 | ||||
| -rw-r--r-- | t/09-faixa.t | 178 |
14 files changed, 515 insertions, 22 deletions
@@ -7,6 +7,7 @@ lib/Cepis/Address.pm lib/Cepis/CSV.pm lib/Cepis/Client.pm lib/Cepis/Error.pm +lib/Cepis/Faixa.pm lib/Cepis/Geo.pm lib/Cepis/Health.pm t/00-load.t @@ -18,4 +19,5 @@ t/05-batch.t t/06-health.t t/07-config.t t/08-tls.t +t/09-faixa.t t/lib/TestServer.pm @@ -252,6 +252,52 @@ Errors stringify to their message, so `warn $err` and `"$err"` do the obvious thing. `$err->retry_after` carries what the server asked for, in seconds, and `$err->status` the HTTP status of the last attempt. +### A not-found that knows where it was + +A `Cepis::Error::CEPNotFound` often knows **which municipality** the CEP would +have belonged to. When it does, `$err->faixa` is a `Cepis::Faixa`: + +```perl +my $address = eval { Cepis::lookup('25845000') }; + +if ( !$address && ref $@ && $@->isa('Cepis::Error::CEPNotFound') ) { + if ( my $faixa = $@->faixa ) { + $faixa->localidade; # "Areal" + $faixa->uf; # "RJ" + $faixa->ibge; # "3300225" + $faixa->ceps; # 118 + $faixa->cep_ini; # "25845001" + $faixa->cep_fim; # "25849899" + $faixa->label; # "Areal/RJ" + } +} +``` + +The message names the municipality too: + +``` +CEP 25845000 does not exist in the DNE database (it would fall in Areal/RJ) +``` + +Worth checking, because a failing CEP is rarely random: it is usually someone +who knows the city and guessed the number, and the classic guess is the `-000` +that 957 municipalities never received. + +Three things this is not: + +- **It is not a success.** The response is still a 404 and `lookup` still + throws. `faixa` is context for the error, never an address. +- **It is not always there.** When the CEP falls in a band no municipality owns, + the API omits the block — `$err->faixa` is then `undef`, never a null-ish + empty object. A malformed block is dropped the same way rather than turning a + clean not-found into a parse failure. +- **`cep_ini`..`cep_fim` is not a list of valid CEPs.** They are the smallest + and the largest CEP that *actually exist* there, not the bounds the Correios + publish — the DNE's own start for Areal is `25845000`, exactly the CEP that + just failed. And the range is sparse: Areal's 118 CEPs are scattered across + the ~4,900 numbers between them. That is what `ceps` is for. Do not iterate + the interval. + `Cepis::Error::InvalidResponse` almost always means something between you and the API is answering instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error page. Useful as a diagnosis: the problem is not in your code. @@ -269,7 +315,7 @@ bring up a real HTTP server on loopback. make # list the targets make test # run the suite with prove make lint # perl -c over lib, t and Makefile.PL -make build # build Cepis-0.1.0.tar.gz +make build # build Cepis-0.2.0.tar.gz make check # lint, then test, then build ``` diff --git a/lib/Cepis.pm b/lib/Cepis.pm index 224d80d..1ea5b00 100644 --- a/lib/Cepis.pm +++ b/lib/Cepis.pm @@ -8,10 +8,11 @@ use Exporter 5.57 qw(import); use Cepis::Address; use Cepis::Client; use Cepis::Error; +use Cepis::Faixa; use Cepis::Geo; use Cepis::Health; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; our @EXPORT_OK = qw(lookup lookup_many health normalize_cep); @@ -101,6 +102,18 @@ the lot, and the split below it is by whether retrying helps: } } +A C<CEPNotFound> often knows B<where> the CEP would have been. When the API says +so, C<< $err->faixa >> is a L<Cepis::Faixa> naming the municipality that owns +that band, and the message names it too: + + # "CEP 25845000 does not exist in the DNE database (it would fall in Areal/RJ)" + +It is still a failure — C<faixa> is context for the error, never an address — +and it is C<undef> when the CEP fell in a band no municipality owns. It is worth +checking because a failing CEP is rarely random: it is usually someone who knows +the city and guessed the number, and the classic guess is the C<-000> that 957 +municipalities never received. + =head2 Field names The API surface is English, but the B<data fields keep the API's own Portuguese @@ -169,7 +182,7 @@ The L<Cepis::Client> behind the functions above, built on first use. =head1 SEE ALSO L<Cepis::Client>, L<Cepis::Address>, L<Cepis::Geo>, L<Cepis::Health>, -L<Cepis::Error> +L<Cepis::Error>, L<Cepis::Faixa> The API itself is documented at L<https://cep.is>. diff --git a/lib/Cepis/Address.pm b/lib/Cepis/Address.pm index a938d6f..d1ae21f 100644 --- a/lib/Cepis/Address.pm +++ b/lib/Cepis/Address.pm @@ -5,7 +5,7 @@ use warnings; use Cepis::Geo; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; our @FIELDS = qw( cep logradouro complemento unidade bairro localidade diff --git a/lib/Cepis/CSV.pm b/lib/Cepis/CSV.pm index 6fabc0c..7ef6880 100644 --- a/lib/Cepis/CSV.pm +++ b/lib/Cepis/CSV.pm @@ -3,7 +3,7 @@ package Cepis::CSV; use strict; use warnings; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub parse_rows { my ($text) = @_; diff --git a/lib/Cepis/Client.pm b/lib/Cepis/Client.pm index 35eb51b..e75868b 100644 --- a/lib/Cepis/Client.pm +++ b/lib/Cepis/Client.pm @@ -13,9 +13,10 @@ use Time::HiRes (); use Cepis::Address; use Cepis::CSV; use Cepis::Error; +use Cepis::Faixa; use Cepis::Health; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; use constant DEFAULT_URL => 'https://cep.is'; use constant MAX_BATCH => 500; @@ -120,8 +121,10 @@ sub lookup { my ( $status, $body ) = $self->_request("/$number"); if ( $status == 404 ) { - Cepis::Error::CEPNotFound->throw( - message => "CEP $number does not exist in the DNE database" ); + my $faixa = _faixa($body); + my $message = "CEP $number does not exist in the DNE database"; + $message .= ' (it would fall in ' . $faixa->label . ')' if $faixa; + Cepis::Error::CEPNotFound->throw( message => $message, faixa => $faixa ); } if ( $status == 400 ) { Cepis::Error::InvalidCEP->throw( @@ -394,6 +397,14 @@ sub _error_message { return "$data->{mensagem}"; } +sub _faixa { + my ($body) = @_; + + my $data = eval { $JSON->decode( defined $body ? $body : '' ) }; + return undef unless ref $data eq 'HASH'; + return Cepis::Faixa->from_json( $data->{faixa} ); +} + sub _decode { my ($body) = @_; return '' unless defined $body; @@ -575,6 +586,10 @@ A 400 from the API is surfaced as L<Cepis::Error::InvalidCEP> carrying the API's own message: the format already passed local validation, so the server knows something this client does not. +A 404 becomes L<Cepis::Error::CEPNotFound>, and that error may carry a +L<Cepis::Faixa> in C<< $err->faixa >> naming the municipality the CEP would have +belonged to — see L</"A not-found that knows where it was">. + Throws L<Cepis::Error::InvalidCEP> when the CEP is not 8 digits (no request is made), L<Cepis::Error::CEPNotFound> when it is well-formed but absent from the database, L<Cepis::Error::RateLimited> on 429 after every attempt, @@ -583,6 +598,27 @@ L<Cepis::Error::Network> when the request never completed, and L<Cepis::Error::InvalidResponse> when a success status carries an unreadable body. +=head3 A not-found that knows where it was + + my $address = eval { $client->lookup('25845000') }; + if ( !$address && ref $@ && $@->isa('Cepis::Error::CEPNotFound') ) { + if ( my $faixa = $@->faixa ) { + printf "%s does not exist in %s (%d CEPs, %s..%s)\n", + '25845000', $faixa->label, $faixa->ceps, + $faixa->cep_ini, $faixa->cep_fim; + } + } + +The API's 404 body may carry a C<faixa> block naming the municipality that owns +the band the CEP fell into. When it does, the error carries a L<Cepis::Faixa> +and its message names the municipality; when the CEP fell in a band nobody owns, +the API omits the block and C<< $err->faixa >> is C<undef>. + +B<It is still a 404.> Nothing here turns a not-found into an address; the +lookup threw, and C<faixa> only says where the CEP would have been. Nor is +C<cep_ini>..C<cep_fim> a list of valid CEPs — the range is sparse, which is what +C<ceps> is for. L<Cepis::Faixa> spells both out. + =head2 lookup_many my $result = $client->lookup_many( \@ceps ); @@ -643,7 +679,7 @@ C<500>, the most CEPs the API accepts in a single batch request. =head1 SEE ALSO -L<Cepis>, L<Cepis::Address>, L<Cepis::Health>, L<Cepis::Error> +L<Cepis>, L<Cepis::Address>, L<Cepis::Health>, L<Cepis::Error>, L<Cepis::Faixa> =head1 LICENCE diff --git a/lib/Cepis/Error.pm b/lib/Cepis/Error.pm index 080b0a1..37f2a08 100644 --- a/lib/Cepis/Error.pm +++ b/lib/Cepis/Error.pm @@ -3,7 +3,7 @@ package Cepis::Error; use strict; use warnings; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; use overload '""' => sub { $_[0]->message }, @@ -30,7 +30,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; package Cepis::Error::CEPNotFound; @@ -38,7 +38,9 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; + +sub faixa { return $_[0]->{faixa} } package Cepis::Error::Network; @@ -46,7 +48,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; package Cepis::Error::ServerError; @@ -54,7 +56,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub status { return $_[0]->{status} } @@ -64,7 +66,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub retry_after { return $_[0]->{retry_after} } @@ -74,7 +76,7 @@ use strict; use warnings; use parent -norequire, 'Cepis::Error'; -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; 1; @@ -171,6 +173,25 @@ message. The CEP is well-formed but does not exist in the DNE database. +=over 4 + +=item C<< $err->faixa >> + +A L<Cepis::Faixa> naming the municipality the CEP I<would> belong to, or +C<undef> when it fell in a band no municipality owns. The API omits the block +rather than sending null, and a malformed one is dropped rather than turning the +not-found into a parse failure. + +It is context for the error, B<not> an address: the lookup still failed. What it +buys is telling the user "25845000 does not exist in Areal/RJ" instead of "not +found" — worth having, because a failing CEP is rarely random. It is usually +someone who knows the city and guessed the number, and the classic guess is the +C<-000> that 957 municipalities never received. + +When it is present, the message already names the municipality. + +=back + =head2 Cepis::Error::Network The request never completed: DNS, refused connection, timeout, TLS. @@ -217,7 +238,7 @@ as a diagnosis: the problem is not in your code. =head1 SEE ALSO -L<Cepis>, L<Cepis::Client> +L<Cepis>, L<Cepis::Client>, L<Cepis::Faixa> =head1 LICENCE diff --git a/lib/Cepis/Faixa.pm b/lib/Cepis/Faixa.pm new file mode 100644 index 0000000..7dabdc3 --- /dev/null +++ b/lib/Cepis/Faixa.pm @@ -0,0 +1,196 @@ +package Cepis::Faixa; + +use strict; +use warnings; + +use Scalar::Util (); + +our $VERSION = '0.2.0'; + +sub from_json { + my ( $class, $data ) = @_; + + return undef unless ref $data eq 'HASH'; + + for my $key (qw(localidade cep_ini cep_fim ceps)) { + return undef unless defined $data->{$key}; + return undef if ref $data->{$key}; + } + return undef unless length "$data->{localidade}"; + for my $key (qw(cep_ini cep_fim)) { + return undef unless "$data->{$key}" =~ /\A[0-9]{8}\z/; + } + return undef unless Scalar::Util::looks_like_number( $data->{ceps} ); + + return bless { + localidade => "$data->{localidade}", + uf => _string( $data->{uf} ), + estado => _string( $data->{estado} ), + regiao => _string( $data->{regiao} ), + ibge => _string( $data->{ibge} ), + cep_ini => "$data->{cep_ini}", + cep_fim => "$data->{cep_fim}", + ceps => int( 0 + $data->{ceps} ), + }, $class; +} + +sub localidade { return $_[0]->{localidade} } +sub uf { return $_[0]->{uf} } +sub estado { return $_[0]->{estado} } +sub regiao { return $_[0]->{regiao} } +sub ibge { return $_[0]->{ibge} } +sub cep_ini { return $_[0]->{cep_ini} } +sub cep_fim { return $_[0]->{cep_fim} } +sub ceps { return $_[0]->{ceps} } + +sub label { + my ($self) = @_; + return length $self->{uf} + ? "$self->{localidade}/$self->{uf}" + : $self->{localidade}; +} + +sub to_hash { + my ($self) = @_; + return { + localidade => $self->{localidade}, + uf => $self->{uf}, + estado => $self->{estado}, + regiao => $self->{regiao}, + ibge => $self->{ibge}, + cep_ini => $self->{cep_ini}, + cep_fim => $self->{cep_fim}, + ceps => $self->{ceps}, + }; +} + +sub _string { + my ($value) = @_; + return '' unless defined $value; + return '' if ref $value; + return "$value"; +} + +1; + +__END__ + +=encoding UTF-8 + +=head1 NAME + +Cepis::Faixa - the municipality a CEP that does not exist would belong to + +=head1 SYNOPSIS + + my $address = eval { Cepis::lookup('25845000') }; + if ( !$address && ref $@ && $@->isa('Cepis::Error::CEPNotFound') ) { + + if ( my $faixa = $@->faixa ) { + print $faixa->localidade, "\n"; # "Areal" + print $faixa->uf, "\n"; # "RJ" + print $faixa->ceps, "\n"; # 118 + printf "%s..%s\n", $faixa->cep_ini, $faixa->cep_fim; + # 25845001..25849899 + } + } + +=head1 DESCRIPTION + +B<This is context for an error, not an address.> The lookup still failed, the +CEP still does not exist, and L<Cepis::Client/lookup> still throws +L<Cepis::Error::CEPNotFound>. What this object adds is I<which municipality +owns the band the CEP fell into> — enough to tell the user "25845000 does not +exist in Areal/RJ" instead of "not found". + +That matters because a failing CEP is rarely random: it is usually someone who +knows the city and guessed the number, and the classic guess is the C<-000> +that 957 municipalities never received. + +The object is reachable as C<< $err->faixa >> on L<Cepis::Error::CEPNotFound>, +and is C<undef> whenever the API sent no C<faixa> — the API B<omits> the field +rather than sending null when the CEP falls in a band no municipality owns. + +=head2 The range is not a list of valid CEPs + +L</cep_ini> and L</cep_fim> are the B<smallest and the largest CEP that actually +exist> in that municipality. They are not the bounds the Correios publish: the +DNE's own start for Areal is C<25845000> — exactly the CEP that just failed. + +The range is also B<sparse>. Areal's 118 CEPs are scattered across the roughly +4,900 numbers between C<25845001> and C<25849899>. Iterating the interval, or +presenting it as "the valid CEPs for this city", produces mostly numbers that do +not exist. L</ceps> is there precisely so that cannot pass unnoticed: it says how +many of them are real. + +Field names are the API's own, in Portuguese. See L<Cepis::Address> for why. + +=head1 CONSTRUCTOR + +=head2 from_json + + my $faixa = Cepis::Faixa->from_json( $hashref ); + +Builds from the C<faixa> block of a 404 body. Returns C<undef> when the block is +missing, empty, not a hash, or malformed. + +Parsing here is deliberately forgiving: a bad C<faixa> must never turn a clean +not-found into a parse failure. The caller still gets its +L<Cepis::Error::CEPNotFound>, just without the extra context. + +=head1 ACCESSORS + +=head2 localidade + +Municipality name. Always present and non-empty — a block without it does not +build. + +=head2 uf + +Two-letter state code. + +=head2 estado + +Full state name. + +=head2 regiao + +Region of the country, such as C<"Sudeste">. + +=head2 ibge + +IBGE municipality code. The stable way to identify the municipality; the name +alone is not unique across states. + +=head2 cep_ini + +The smallest CEP that exists in the municipality, unmasked. Read the warning +above before treating it as a lower bound. + +=head2 cep_fim + +The largest CEP that exists in the municipality, unmasked. + +=head2 ceps + +How many CEPs the municipality actually has, as an integer. Compare it with the +span between L</cep_ini> and L</cep_fim> to see how sparse the range is. + +=head2 label + +C<"Areal/RJ">: the municipality and its state, ready to put in a message. Falls +back to the municipality alone when the API sent no C<uf>. + +=head2 to_hash + +The eight fields as a plain hashref. + +=head1 SEE ALSO + +L<Cepis>, L<Cepis::Error>, L<Cepis::Client> + +=head1 LICENCE + +ISC. Copyright (c) 2026 Murilo Ijanc E<lt>[email protected]<gt>. + +=cut diff --git a/lib/Cepis/Geo.pm b/lib/Cepis/Geo.pm index bee2515..c2c8fa3 100644 --- a/lib/Cepis/Geo.pm +++ b/lib/Cepis/Geo.pm @@ -5,7 +5,7 @@ use warnings; use Scalar::Util (); -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub from_json { my ( $class, $data ) = @_; diff --git a/lib/Cepis/Health.pm b/lib/Cepis/Health.pm index 6782fd0..858b543 100644 --- a/lib/Cepis/Health.pm +++ b/lib/Cepis/Health.pm @@ -5,7 +5,7 @@ use warnings; use Scalar::Util (); -our $VERSION = '0.1.0'; +our $VERSION = '0.2.0'; sub from_json { my ( $class, $data ) = @_; diff --git a/t/00-load.t b/t/00-load.t index 73c8063..a49d73e 100644 --- a/t/00-load.t +++ b/t/00-load.t @@ -9,13 +9,14 @@ my @modules = qw( Cepis::CSV Cepis::Client Cepis::Error + Cepis::Faixa Cepis::Geo Cepis::Health ); use_ok($_) for @modules; -is( $Cepis::VERSION, '0.1.0', 'Cepis is at version 0.1.0' ); +is( $Cepis::VERSION, '0.2.0', 'Cepis is at version 0.2.0' ); for my $module (@modules) { no strict 'refs'; diff --git a/t/03-lookup.t b/t/03-lookup.t index 5fb3937..58d7a66 100644 --- a/t/03-lookup.t +++ b/t/03-lookup.t @@ -161,7 +161,7 @@ use Cepis; like( $client->lookup('01001000')->unidade, - qr{\Acepis-perl/0\.1\.0 \(\+https://cep\.is\)\z}, + qr{\Acepis-perl/0\.2\.0 \(\+https://cep\.is\)\z}, 'the request identifies the library' ); $server->stop; diff --git a/t/07-config.t b/t/07-config.t index 5c6f19b..3398fd6 100644 --- a/t/07-config.t +++ b/t/07-config.t @@ -31,7 +31,7 @@ for my $bad ( } 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{\Acepis-perl/0\.2\.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, diff --git a/t/09-faixa.t b/t/09-faixa.t new file mode 100644 index 0000000..ea3f76a --- /dev/null +++ b/t/09-faixa.t @@ -0,0 +1,178 @@ +use strict; +use warnings; +use utf8; + +use FindBin (); +use lib "$FindBin::Bin/lib"; + +use Test::More; +use TestServer qw(json new_client); + +use Cepis; + +sub areal { + return { + localidade => 'Areal', + uf => 'RJ', + estado => 'Rio de Janeiro', + regiao => 'Sudeste', + ibge => '3300225', + cep_ini => '25845001', + cep_fim => '25849899', + ceps => 118, + }; +} + +sub not_found { + my (%extra) = @_; + return json( + { erro => 'cep_nao_encontrado', + mensagem => 'CEP não existe na base do DNE', + %extra, + } + ); +} + +# The 404 that knows where the CEP would have been. +{ + my $server = TestServer->new( + routes => { '/25845000' => [ 404, not_found( faixa => areal() ), {} ] } ); + my $client = new_client($server); + + my $ok = eval { $client->lookup('25845000'); 1 }; + my $err = $@; + + ok( !$ok, 'a CEP with a faixa still throws' ); + isa_ok( $err, 'Cepis::Error::CEPNotFound' ); + + my $faixa = $err->faixa; + isa_ok( $faixa, 'Cepis::Faixa' ); + is( $faixa->localidade, 'Areal', 'localidade' ); + is( $faixa->uf, 'RJ', 'uf' ); + is( $faixa->estado, 'Rio de Janeiro', 'estado' ); + is( $faixa->regiao, 'Sudeste', 'regiao' ); + is( $faixa->ibge, '3300225', 'ibge' ); + is( $faixa->cep_ini, '25845001', 'cep_ini' ); + is( $faixa->cep_fim, '25849899', 'cep_fim' ); + is( $faixa->ceps, 118, 'ceps counts what actually exists' ); + is( $faixa->label, 'Areal/RJ', 'label is ready to print' ); + + like( "$err", qr/Areal/, 'the message names the municipality' ); + like( "$err", qr/25845000/, 'and still names the CEP' ); + is( $server->count('/25845000'), 1, 'a 404 with a faixa is not retried' ); + + is_deeply( $faixa->to_hash, areal(), 'to_hash gives the eight fields back' ); + + # The DNE's own start for Areal is 25845000 — the CEP that just failed. What + # comes back is the smallest CEP that exists, which is a different number. + isnt( $faixa->cep_ini, '25845000', + 'cep_ini is not the published start of the band' ); + + # 118 CEPs across ~4,900 numbers: the range is sparse, and ceps is what says + # so. Nothing here may be read as a list of valid CEPs. + cmp_ok( $faixa->ceps, '<', $faixa->cep_fim - $faixa->cep_ini, + 'the range is far sparser than its span' ); + + $server->stop; +} + +# The 404 that does not: a band no municipality owns. The API omits the field. +{ + my $server + = TestServer->new( routes => { '/99999999' => [ 404, not_found(), {} ] } ); + my $client = new_client($server); + + my $ok = eval { $client->lookup('99999999'); 1 }; + my $err = $@; + + ok( !$ok, 'a CEP with no faixa throws the same error' ); + isa_ok( $err, 'Cepis::Error::CEPNotFound' ); + is( $err->faixa, undef, 'and faixa is undef, because the API omitted it' ); + like( "$err", qr/99999999/, 'the message names the CEP' ); + unlike( "$err", qr/would fall in/, 'and promises nothing about where it was' ); + + $server->stop; +} + +# A faixa that cannot be trusted must never cost the caller its clean 404. +{ + my @broken = ( + [ 'null' => undef ], + [ 'an empty hash' => {} ], + [ 'a scalar' => 'Areal' ], + [ 'an array' => [ 'Areal', 'RJ' ] ], + [ 'no localidade' => { %{ areal() }, localidade => '' } ], + [ 'no ceps' => { map { $_ => areal()->{$_} } qw(localidade uf cep_ini cep_fim) } ], + [ 'a non-numeric ceps' => { %{ areal() }, ceps => 'many' } ], + [ 'a malformed cep_ini' => { %{ areal() }, cep_ini => '2584500' } ], + [ 'a masked cep_fim' => { %{ areal() }, cep_fim => '25849-899' } ], + [ 'a nested localidade' => { %{ areal() }, localidade => { name => 'Areal' } } ], + ); + + for my $case (@broken) { + my ( $label, $faixa ) = @$case; + + my $server = TestServer->new( + routes => { '/25845000' => [ 404, not_found( faixa => $faixa ), {} ] } ); + my $client = new_client($server); + + my $ok = eval { $client->lookup('25845000'); 1 }; + my $err = $@; + + ok( !$ok, "a faixa that is $label still throws" ); + isa_ok( $err, 'Cepis::Error::CEPNotFound' ); + is( $err->faixa, undef, "a faixa that is $label is dropped" ); + + $server->stop; + } +} + +# A 404 body that is not JSON at all — a proxy, an HTML page — is still a 404. +{ + my $server = TestServer->new( + routes => { + '/25845000' => [ 404, '<html>404 Not Found</html>', {} ], + '/25845002' => [ 404, '', {} ], + '/25845003' => [ 404, '[1,2,3]', {} ], + } + ); + my $client = new_client($server); + + for my $cep (qw(25845000 25845002 25845003)) { + my $ok = eval { $client->lookup($cep); 1 }; + ok( !$ok, "an unparseable 404 body still throws for $cep" ); + isa_ok( $@, 'Cepis::Error::CEPNotFound' ); + is( $@->faixa, undef, 'with no faixa' ); + } + + $server->stop; +} + +# from_json on its own, without a request in the way. +{ + isa_ok( Cepis::Faixa->from_json( areal() ), 'Cepis::Faixa' ); + is( Cepis::Faixa->from_json(undef), undef, 'from_json(undef) is undef' ); + is( Cepis::Faixa->from_json( [] ), undef, 'from_json on an array is undef' ); + is( Cepis::Faixa->from_json('Areal'), undef, 'from_json on a string is undef' ); + + my $partial = Cepis::Faixa->from_json( + { localidade => 'Areal', cep_ini => '25845001', cep_fim => '25849899', ceps => 118 } ); + isa_ok( $partial, 'Cepis::Faixa' ); + is( $partial->uf, '', 'a missing uf is an empty string, as elsewhere' ); + is( $partial->estado, '', 'and so is a missing estado' ); + is( $partial->label, 'Areal', 'label falls back to the municipality alone' ); + + my $numeric = Cepis::Faixa->from_json( { %{ areal() }, ceps => '118' } ); + is( $numeric->ceps, 118, 'ceps comes back as a number' ); +} + +# An unknown CEP with a faixa is still undef in a batch: the CSV path is +# untouched by any of this. +{ + my $address = Cepis::Address->from_json( { %{ areal() }, cep => '25845000' } ); + is( $address->localidade, 'Areal', 'Cepis::Address is unaffected' ); + is( $address->geo, undef, 'and grows no faixa of its own' ); + is( $address->field('cep_ini'), '25845001', 'stray fields land in raw as ever' ); +} + +done_testing(); |