diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:46:40 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:46:40 -0300 |
| commit | 83ce95cf10fd9e9d581362453c558239d37ae06e (patch) | |
| tree | dcaf1e9258e1c55c2df19f6556e15c39eaf622bb /internal/lookup/lookup_test.go | |
| parent | 3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (diff) | |
| download | telegram-0.2.0.tar.gz | |
Name the municipality when the CEP does not existv0.2.0
Answering only "não encontrei" wastes the trip. A CEP that fails is
almost never a random number: it is someone who knows the city and
guessed the rest, most often at the "-000" that 957 municipalities never
received. The API now names that municipality on the 404, so the reply
names it too.
It stays an answer to a failure. No 📮, no address block, and no CEP
suggested from inside the range — the range is sparse (Areal's 118 CEPs
span nearly five thousand numbers), so the reply gives the count and says
so instead of letting the two ends read as a list of valid CEPs.
cep.is/go v0.1.0 discards the 404 body, so internal/lookup reads it with
one extra GET, on that path alone and never inside a retry. Everything
that can go wrong there — a slow answer, broken JSON, a faixa naming no
municipality — falls back to the reply the bot has always given. The
wrapper goes away the day the library exposes the field.
Diffstat (limited to 'internal/lookup/lookup_test.go')
| -rw-r--r-- | internal/lookup/lookup_test.go | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go new file mode 100644 index 0000000..6eb7b4e --- /dev/null +++ b/internal/lookup/lookup_test.go @@ -0,0 +1,279 @@ +package lookup + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + cepis "cep.is/go" +) + +const areal404 = `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", + "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste", + "ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}}` + +const plain404 = `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}` + +const address200 = `{"cep":"01001000","logradouro":"Praça da Sé","bairro":"Sé", + "localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste","ddd":"11"}` + +// fakeAPI answers every request with the same status and body, and counts how +// many arrived: the count is what proves the extra request happens only on the +// 404 path. +type fakeAPI struct { + server *httptest.Server + requests atomic.Int64 + + // delay is applied from the delayFrom-th request on, which is how the + // second request — the one that reads the faixa — is made slow without + // slowing the lookup that precedes it. + delay time.Duration + delayFrom int64 +} + +func newFakeAPI(t *testing.T, status int, body string) *fakeAPI { + t.Helper() + f := &fakeAPI{delayFrom: 1} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := f.requests.Add(1) + if f.delay > 0 && n >= f.delayFrom { + select { + case <-time.After(f.delay): + case <-r.Context().Done(): + return + } + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + w.Write([]byte(body)) + })) + t.Cleanup(f.server.Close) + return f +} + +func newClient(t *testing.T, f *fakeAPI, opts ...Option) *Client { + t.Helper() + api, err := cepis.New( + cepis.WithBaseURL(f.server.URL), + cepis.WithMaxAttempts(1), + cepis.WithTimeout(2*time.Second), + ) + if err != nil { + t.Fatalf("cepis.New: %v", err) + } + return New(api, opts...) +} + +func TestNotFoundCarriesTheMunicipality(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, areal404) + _, err := newClient(t, f).Lookup(context.Background(), "25845-000") + + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + faixa := FaixaOf(err) + if faixa == nil { + t.Fatal("the faixa on the 404 was dropped") + } + if faixa.Place() != "Areal/RJ" { + t.Fatalf("place = %q, want Areal/RJ", faixa.Place()) + } + if faixa.CEPs != 118 { + t.Fatalf("ceps = %d, want 118", faixa.CEPs) + } + if faixa.CEPIni != "25845001" || faixa.CEPFim != "25849899" { + t.Fatalf("range = %s..%s, want 25845001..25849899", faixa.CEPIni, faixa.CEPFim) + } + if faixa.IBGE != "3300225" || faixa.Estado != "Rio de Janeiro" || faixa.Regiao != "Sudeste" { + t.Fatalf("faixa lost fields: %+v", faixa) + } +} + +func TestNotFoundWithoutFaixaIsUntouched(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, plain404) + _, err := newClient(t, f).Lookup(context.Background(), "99999999") + + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("invented a municipality: %+v", faixa) + } + var notFound *NotFoundError + if errors.As(err, ¬Found) { + t.Fatal("a 404 without a faixa should come back as the library's own error") + } +} + +// A faixa the bot cannot trust must cost nothing: the reply falls back to the +// plain one, and never to a panic or an unanswered message. +func TestMalformedFaixaFallsBack(t *testing.T) { + bodies := map[string]string{ + "empty body": ``, + "not JSON": `<html>captive portal</html>`, + "truncated": `{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal"`, + "faixa is null": `{"erro":"cep_nao_encontrado","faixa":null}`, + "faixa is a string": `{"erro":"cep_nao_encontrado","faixa":"Areal/RJ"}`, + "faixa is an array": `{"erro":"cep_nao_encontrado","faixa":[{"localidade":"Areal"}]}`, + "no localidade": `{"faixa":{"uf":"RJ","cep_ini":"25845001","cep_fim":"25849899"}}`, + "blank localidade": `{"faixa":{"localidade":" ","uf":"RJ"}}`, + "ceps is a string": `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":"118"}}`, + "whole body is null": `null`, + "whole body is array": `[]`, + } + + for name, body := range bodies { + t.Run(name, func(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, body) + _, err := newClient(t, f).Lookup(context.Background(), "25845000") + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("trusted a broken faixa: %+v", faixa) + } + }) + } +} + +// A negative count is nonsense the formatter would print verbatim, so it is +// flattened to "unknown" while the municipality itself is kept. +func TestNegativeCountIsDropped(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":-3}}`) + _, err := newClient(t, f).Lookup(context.Background(), "25845000") + + faixa := FaixaOf(err) + if faixa == nil { + t.Fatal("the municipality should have survived a bad count") + } + if faixa.CEPs != 0 { + t.Fatalf("ceps = %d, want 0", faixa.CEPs) + } +} + +func TestSuccessCostsOneRequest(t *testing.T) { + f := newFakeAPI(t, http.StatusOK, address200) + address, err := newClient(t, f).Lookup(context.Background(), "01001000") + + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Localidade != "São Paulo" { + t.Fatalf("localidade = %q", address.Localidade) + } + if got := f.requests.Load(); got != 1 { + t.Fatalf("the API saw %d requests for a hit, want 1", got) + } +} + +func TestOtherFailuresAskOnlyOnce(t *testing.T) { + f := newFakeAPI(t, http.StatusInternalServerError, `{"erro":"interno"}`) + _, err := newClient(t, f).Lookup(context.Background(), "50000000") + + if !errors.Is(err, cepis.ErrServer) { + t.Fatalf("err = %v, want a server error", err) + } + if got := f.requests.Load(); got != 1 { + t.Fatalf("the API saw %d requests for a 500, want 1", got) + } +} + +// The faixa is worth one short wait and no more. A slow API still gets an +// answer out to the user, without the municipality. +func TestSlowFaixaIsAbandoned(t *testing.T) { + f := newFakeAPI(t, http.StatusNotFound, areal404) + client := newClient(t, f, WithTimeout(20*time.Millisecond)) + f.delay, f.delayFrom = 500*time.Millisecond, 2 + + started := time.Now() + _, err := client.Lookup(context.Background(), "25845000") + elapsed := time.Since(started) + + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("used a faixa that arrived too late: %+v", faixa) + } + if elapsed > 400*time.Millisecond { + t.Fatalf("waited %v for the faixa, want it abandoned quickly", elapsed) + } +} + +// A CEP that stopped being a 404 between the two requests is not a +// municipality: the second answer is ignored rather than mixed into the first. +func TestFaixaIgnoresANonNotFoundSecondAnswer(t *testing.T) { + var count atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if count.Add(1) == 1 { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(plain404)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(address200)) + })) + t.Cleanup(server.Close) + + api, err := cepis.New(cepis.WithBaseURL(server.URL), cepis.WithMaxAttempts(1)) + if err != nil { + t.Fatalf("cepis.New: %v", err) + } + + _, err = New(api).Lookup(context.Background(), "25845000") + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("err = %v, want a not-found error", err) + } + if faixa := FaixaOf(err); faixa != nil { + t.Fatalf("read a municipality out of a 200: %+v", faixa) + } +} + +func TestFaixaOfAcceptsAnything(t *testing.T) { + for name, err := range map[string]error{ + "nil": nil, + "unrelated": errors.New("boom"), + "not found": cepis.ErrCEPNotFound, + "no faixa": &NotFoundError{Err: cepis.ErrCEPNotFound}, + "wrapped": &NotFoundError{Faixa: &Faixa{Localidade: "Areal", UF: "RJ"}, Err: cepis.ErrCEPNotFound}, + "empty type": &NotFoundError{}, + } { + if name == "wrapped" { + if got := FaixaOf(err); got == nil || got.Place() != "Areal/RJ" { + t.Fatalf("%s: FaixaOf = %+v", name, got) + } + continue + } + if got := FaixaOf(err); got != nil { + t.Fatalf("%s: FaixaOf = %+v, want nil", name, got) + } + } +} + +// The zero value still matches the sentinel, so no construction of this type +// can ever produce an error the bot fails to recognise as a not-found. +func TestEmptyNotFoundErrorStillMatches(t *testing.T) { + var err error = &NotFoundError{} + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatal("an empty NotFoundError should still be a not-found") + } + if err.Error() == "" { + t.Fatal("an empty NotFoundError should still say something") + } +} + +func TestPlaceWithoutUF(t *testing.T) { + faixa := &Faixa{Localidade: "Areal"} + if faixa.Place() != "Areal" { + t.Fatalf("place = %q, want Areal", faixa.Place()) + } + var missing *Faixa + if missing.Place() != "" { + t.Fatalf("place = %q, want empty", missing.Place()) + } +} |