summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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();