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": `captive portal`, "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()) } }