summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 20:34:36 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 20:34:36 -0300
commitf2f3e5b5b22f5db49670391fb747a52fd492b9db (patch)
tree3d02d13f3111bdd7381470d3fd3db1f58e00bc8c
parent0bc578d6d334c7f1ccaf329cd61bb8bce76b7f59 (diff)
downloadperl-f2f3e5b5b22f5db49670391fb747a52fd492b9db.tar.gz
Carry the municipality on a CEP that does not existHEADv0.2.0main
A CEP that fails is rarely a random number. It is usually someone who knows the city and guessed the digits, and the classic guess is the -000 that 957 municipalities never received. The API now answers those 404s with a faixa block naming the municipality that owns the band, so the library can say "25845000 does not exist in Areal/RJ" instead of "not found". It stays an error: lookup still throws Cepis::Error::CEPNotFound, and the faixa hangs off the exception rather than turning a miss into an address. Parsing it is deliberately forgiving — a missing, empty or malformed block yields undef, because losing the clean not-found to a parse failure would be a worse outcome than losing the context. cep_ini and cep_fim are the smallest and largest CEP that actually exist, not the bounds the Correios publish, and the interval between them is sparse. That is why ceps is exposed alongside them and why the docs say twice that the pair is not a list of valid CEPs.
-rw-r--r--MANIFEST2
-rw-r--r--README.md48
-rw-r--r--lib/Cepis.pm17
-rw-r--r--lib/Cepis/Address.pm2
-rw-r--r--lib/Cepis/CSV.pm2
-rw-r--r--lib/Cepis/Client.pm44
-rw-r--r--lib/Cepis/Error.pm37
-rw-r--r--lib/Cepis/Faixa.pm196
-rw-r--r--lib/Cepis/Geo.pm2
-rw-r--r--lib/Cepis/Health.pm2
-rw-r--r--t/00-load.t3
-rw-r--r--t/03-lookup.t2
-rw-r--r--t/07-config.t2
-rw-r--r--t/09-faixa.t178
14 files changed, 515 insertions, 22 deletions
diff --git a/MANIFEST b/MANIFEST
index 115bae1..caf930e 100644
--- a/MANIFEST
+++ b/MANIFEST
@@ -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
diff --git a/README.md b/README.md
index b799f45..48336b3 100644
--- a/README.md
+++ b/README.md
@@ -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();