diff options
Diffstat (limited to 'cepis_test.go')
| -rw-r--r-- | cepis_test.go | 694 |
1 files changed, 694 insertions, 0 deletions
diff --git a/cepis_test.go b/cepis_test.go new file mode 100644 index 0000000..888ded5 --- /dev/null +++ b/cepis_test.go @@ -0,0 +1,694 @@ +package cepis_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + cepis "cep.is/go" +) + +const seAddressJSON = `{"cep":"01001000","logradouro":"Praça da Sé",` + + `"complemento":"lado ímpar","unidade":"","bairro":"Sé",` + + `"localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste",` + + `"ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107"}` + +const csvHeader = "cep,logradouro,complemento,unidade,bairro,localidade,uf," + + "estado,regiao,ibge,gia,ddd,siafi" + +type stub struct { + status int + body string + header map[string]string +} + +type fakeAPI struct { + mu sync.Mutex + routes map[string][]stub + calls []string + custom func(path string, n int) stub + server *httptest.Server +} + +func newFakeAPI(t *testing.T) *fakeAPI { + t.Helper() + api := &fakeAPI{routes: map[string][]stub{}} + api.server = httptest.NewServer(http.HandlerFunc(api.serve)) + t.Cleanup(api.server.Close) + return api +} + +func (a *fakeAPI) serve(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + a.calls = append(a.calls, r.URL.RequestURI()) + count := len(a.calls) + var answer stub + if a.custom != nil { + answer = a.custom(r.URL.RequestURI(), count) + } else { + answers, ok := a.routes[r.URL.RequestURI()] + switch { + case !ok || len(answers) == 0: + answer = stub{status: 404, body: `{"erro":"cep_nao_encontrado"}`} + case len(answers) == 1: + answer = answers[0] + default: + answer = answers[0] + a.routes[r.URL.RequestURI()] = answers[1:] + } + } + a.mu.Unlock() + + for key, value := range answer.header { + w.Header().Set(key, value) + } + w.WriteHeader(answer.status) + fmt.Fprint(w, answer.body) +} + +func (a *fakeAPI) respond(path string, answers ...stub) { + a.mu.Lock() + defer a.mu.Unlock() + a.routes[path] = answers +} + +func (a *fakeAPI) countPrefix(prefix string) int { + a.mu.Lock() + defer a.mu.Unlock() + total := 0 + for _, call := range a.calls { + if strings.HasPrefix(call, prefix) { + total++ + } + } + return total +} + +func (a *fakeAPI) totalCalls() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.calls) +} + +func newClient(t *testing.T, api *fakeAPI, opts ...cepis.Option) *cepis.Client { + t.Helper() + base := []cepis.Option{ + cepis.WithBaseURL(api.server.URL), + cepis.WithTimeout(5 * time.Second), + cepis.WithBackoff(time.Millisecond, 10*time.Millisecond), + } + client, err := cepis.New(append(base, opts...)...) + if err != nil { + t.Fatalf("New: %v", err) + } + return client +} + +func ok(status int, body string) stub { + return stub{status: status, body: body} +} + +func TestNormalizeCEPAcceptsMaskedAndUnmasked(t *testing.T) { + for _, value := range []string{"01001000", "01001-000", " 01001-000 ", "01001.000"} { + got, err := cepis.NormalizeCEP(value) + if err != nil { + t.Fatalf("NormalizeCEP(%q): %v", value, err) + } + if got != "01001000" { + t.Errorf("NormalizeCEP(%q) = %q, want 01001000", value, got) + } + } +} + +func TestNormalizeCEPRejectsBadFormat(t *testing.T) { + for _, value := range []string{"", "123", "0100100a", "010010000", "abcdefgh"} { + if _, err := cepis.NormalizeCEP(value); !errors.Is(err, cepis.ErrInvalidCEP) { + t.Errorf("NormalizeCEP(%q) error = %v, want ErrInvalidCEP", value, err) + } + } +} + +func TestErrorsShareOneRoot(t *testing.T) { + _, err := cepis.NormalizeCEP("123") + if !errors.Is(err, cepis.Err) { + t.Fatalf("error %v does not match cepis.Err", err) + } +} + +func TestLookupSuccess(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(200, seAddressJSON)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001-000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Localidade != "São Paulo" || address.UF != "SP" { + t.Errorf("got %q/%q, want São Paulo/SP", address.Localidade, address.UF) + } + if address.IBGE != "3550308" || address.DDD != "11" { + t.Errorf("got ibge %q ddd %q", address.IBGE, address.DDD) + } + if address.FormattedCEP() != "01001-000" { + t.Errorf("FormattedCEP = %q", address.FormattedCEP()) + } + if address.Geo != nil { + t.Errorf("Geo = %+v, want nil", address.Geo) + } +} + +func TestLookupSendsHeaders(t *testing.T) { + var agent, accept string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agent, accept = r.Header.Get("User-Agent"), r.Header.Get("Accept") + fmt.Fprint(w, seAddressJSON) + })) + defer server.Close() + + client, err := cepis.New(cepis.WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := client.Lookup(context.Background(), "01001000"); err != nil { + t.Fatalf("Lookup: %v", err) + } + if !strings.HasPrefix(agent, "cepis-go/") { + t.Errorf("User-Agent = %q", agent) + } + if accept == "" { + t.Error("Accept header not sent") + } +} + +func TestLookupReadsTheCoordinate(t *testing.T) { + api := newFakeAPI(t) + body := withGeo(t, `{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}`) + api.respond("/01310100", ok(200, body)) + + address, err := newClient(t, api).Lookup(context.Background(), "01310100") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Geo == nil { + t.Fatal("Geo is nil") + } + if address.Geo.Lat != -23.5 || address.Geo.Lon != -46.6 || address.Geo.RaioM != 120 { + t.Errorf("Geo = %+v", *address.Geo) + } + if !address.Geo.IsPrecise() { + t.Error("IsPrecise = false, want true for precisao=logradouro") + } +} + +func TestLookupMunicipalityCoordinateIsNotPrecise(t *testing.T) { + api := newFakeAPI(t) + body := withGeo(t, `{"lat":-23.5,"lon":-46.6,"raio_m":22000,"precisao":"localidade"}`) + api.respond("/01001000", ok(200, body)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Geo == nil || address.Geo.IsPrecise() { + t.Errorf("Geo = %+v, want an imprecise coordinate", address.Geo) + } +} + +func TestLookupMalformedGeoDoesNotLoseTheAddress(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(200, withGeo(t, `{"lat":"not a number"}`))) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Geo != nil { + t.Errorf("Geo = %+v, want nil", address.Geo) + } + if address.Localidade != "São Paulo" { + t.Errorf("Localidade = %q, the address was lost", address.Localidade) + } +} + +func TestLookupToleratesUnknownFields(t *testing.T) { + api := newFakeAPI(t) + body := strings.TrimSuffix(seAddressJSON, "}") + `,"campo_do_futuro":"value"}` + api.respond("/01001000", ok(200, body)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if got := address.Field("campo_do_futuro"); got != "value" { + t.Errorf("Field(campo_do_futuro) = %q, want value", got) + } +} + +func TestLookupNotFound(t *testing.T) { + api := newFakeAPI(t) + api.respond("/99999999", ok(404, `{"erro":"cep_nao_encontrado","mensagem":"gone"}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "99999999") + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("error = %v, want ErrCEPNotFound", err) + } + var cerr *cepis.Error + if !errors.As(err, &cerr) { + t.Fatalf("error %v is not a *cepis.Error", err) + } + if cerr.CEP != "99999999" || cerr.Status != 404 { + t.Errorf("Error = %+v", cerr) + } +} + +func TestLookupNotFoundIsNotRetried(t *testing.T) { + api := newFakeAPI(t) + api.respond("/99999999", ok(404, `{"erro":"cep_nao_encontrado"}`)) + + if _, err := newClient(t, api).Lookup(context.Background(), "99999999"); err == nil { + t.Fatal("expected an error") + } + if got := api.countPrefix("/99999999"); got != 1 { + t.Errorf("%d requests, want 1: a 404 is definitive", got) + } +} + +func TestLookupInvalidCEPNeverLeavesTheMachine(t *testing.T) { + api := newFakeAPI(t) + + _, err := newClient(t, api).Lookup(context.Background(), "123") + if !errors.Is(err, cepis.ErrInvalidCEP) { + t.Fatalf("error = %v, want ErrInvalidCEP", err) + } + if got := api.totalCalls(); got != 0 { + t.Errorf("%d requests made, want 0", got) + } +} + +func TestLookupAPI400BecomesInvalidCEP(t *testing.T) { + api := newFakeAPI(t) + api.respond("/00000000", ok(400, `{"erro":"cep_invalido","mensagem":"rejected by the API"}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "00000000") + if !errors.Is(err, cepis.ErrInvalidCEP) { + t.Fatalf("error = %v, want ErrInvalidCEP", err) + } + if !strings.Contains(err.Error(), "rejected by the API") { + t.Errorf("error %q does not carry the API message", err) + } + if got := api.countPrefix("/00000000"); got != 1 { + t.Errorf("%d requests, want 1: a 400 is definitive", got) + } +} + +func TestLookupResponseThatIsNotJSON(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(200, "<html>captive portal</html>")) + + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrInvalidResponse) { + t.Fatalf("error = %v, want ErrInvalidResponse", err) + } +} + +func TestRetriesOn500ThenSucceeds(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, `{"erro":"interno"}`), ok(200, seAddressJSON)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Localidade != "São Paulo" { + t.Errorf("Localidade = %q", address.Localidade) + } + if got := api.countPrefix("/01001000"); got != 2 { + t.Errorf("%d requests, want 2", got) + } +} + +func TestRetryExhaustsAttempts(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(503, `{"erro":"interno"}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrServer) { + t.Fatalf("error = %v, want ErrServer", err) + } + var cerr *cepis.Error + if errors.As(err, &cerr); cerr.Status != 503 { + t.Errorf("Status = %d, want 503", cerr.Status) + } + if got := api.countPrefix("/01001000"); got != 3 { + t.Errorf("%d requests, want the documented default of 3", got) + } +} + +func TestMaxAttemptsOneDisablesRetry(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, "{}")) + + client := newClient(t, api, cepis.WithMaxAttempts(1)) + if _, err := client.Lookup(context.Background(), "01001000"); !errors.Is(err, cepis.ErrServer) { + t.Fatalf("error = %v, want ErrServer", err) + } + if got := api.countPrefix("/01001000"); got != 1 { + t.Errorf("%d requests, want 1", got) + } +} + +func TestRateLimitedCarriesRetryAfter(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": "7"}}) + + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrRateLimited) { + t.Fatalf("error = %v, want ErrRateLimited", err) + } + var cerr *cepis.Error + if errors.As(err, &cerr); cerr.RetryAfter != 7*time.Second { + t.Errorf("RetryAfter = %v, want 7s", cerr.RetryAfter) + } +} + +func TestRetryAfterHonoursTheCap(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": "3600"}}) + + started := time.Now() + if _, err := newClient(t, api).Lookup(context.Background(), "01001000"); err == nil { + t.Fatal("expected an error") + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Errorf("waited %v: an absurd Retry-After must not park the caller", elapsed) + } +} + +func TestRetryAfterAcceptsAnHTTPDate(t *testing.T) { + api := newFakeAPI(t) + when := time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat) + api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": when}}) + + started := time.Now() + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrRateLimited) { + t.Fatalf("error = %v, want ErrRateLimited", err) + } + var cerr *cepis.Error + errors.As(err, &cerr) + if cerr.RetryAfter < 30*time.Second { + t.Errorf("RetryAfter = %v, the HTTP date form was not parsed", cerr.RetryAfter) + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Errorf("waited %v: the cap must apply to the date form too", elapsed) + } +} + +func TestNetworkErrorWhenNothingListens(t *testing.T) { + api := newFakeAPI(t) + url := api.server.URL + api.server.Close() + + client, err := cepis.New( + cepis.WithBaseURL(url), + cepis.WithMaxAttempts(2), + cepis.WithBackoff(time.Millisecond, 10*time.Millisecond), + cepis.WithTimeout(time.Second), + ) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := client.Lookup(context.Background(), "01001000"); !errors.Is(err, cepis.ErrNetwork) { + t.Fatalf("error = %v, want ErrNetwork", err) + } +} + +func TestTotalTimeoutBoundsTheWholeCall(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, "{}")) + + client := newClient(t, api, + cepis.WithMaxAttempts(50), + cepis.WithBackoff(50*time.Millisecond, 50*time.Millisecond), + cepis.WithTotalTimeout(300*time.Millisecond), + ) + + started := time.Now() + if _, err := client.Lookup(context.Background(), "01001000"); err == nil { + t.Fatal("expected an error") + } + if elapsed := time.Since(started); elapsed > 3*time.Second { + t.Errorf("took %v, the deadline did not cut the run short", elapsed) + } + if got := api.countPrefix("/01001000"); got >= 50 { + t.Errorf("%d requests: the deadline did not cut the run short", got) + } +} + +func TestContextCancellationStopsTheCall(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, "{}")) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client := newClient(t, api, + cepis.WithMaxAttempts(50), + cepis.WithBackoff(30*time.Millisecond, 30*time.Millisecond), + ) + if _, err := client.Lookup(ctx, "01001000"); err == nil { + t.Fatal("expected an error") + } + if got := api.countPrefix("/01001000"); got >= 50 { + t.Errorf("%d requests: the cancelled context was ignored", got) + } +} + +func csvBody(rows ...string) string { + return csvHeader + "\n" + strings.Join(rows, "\n") + "\n" +} + +func TestBatchPreservesOrderAndMarksMissing(t *testing.T) { + api := newFakeAPI(t) + body := csvBody( + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", + "99999999,,,,,,,,,,,,", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001", + ) + api.respond("/csv?ceps=01001000%2C99999999%2C20040020", ok(200, body)) + + result, err := newClient(t, api).LookupMany( + context.Background(), + []string{"01001000", "99999999", "20040020"}, + ) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if len(result) != 3 { + t.Fatalf("len = %d, want 3", len(result)) + } + if result[0] == nil || result[0].Localidade != "São Paulo" { + t.Errorf("result[0] = %+v", result[0]) + } + if result[1] != nil { + t.Errorf("result[1] = %+v, want nil for a missing CEP", result[1]) + } + if result[2] == nil || result[2].UF != "RJ" { + t.Errorf("result[2] = %+v", result[2]) + } + if result[0].Geo != nil { + t.Error("a batch must never carry a coordinate") + } +} + +func TestBatchParsesQuotedFields(t *testing.T) { + api := newFakeAPI(t) + body := csvBody(`01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1`) + api.respond("/csv?ceps=01001000", ok(200, body)) + + result, err := newClient(t, api).LookupMany(context.Background(), []string{"01001000"}) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if result[0] == nil || result[0].Logradouro != "Rua Um, Dois" { + t.Errorf("result[0] = %+v", result[0]) + } +} + +func TestBatchEmptyListMakesNoRequest(t *testing.T) { + api := newFakeAPI(t) + + result, err := newClient(t, api).LookupMany(context.Background(), nil) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if len(result) != 0 { + t.Errorf("len = %d, want 0", len(result)) + } + if got := api.totalCalls(); got != 0 { + t.Errorf("%d requests made, want 0", got) + } +} + +func TestBatchChunksAboveTheAPILimit(t *testing.T) { + api := newFakeAPI(t) + + ceps := make([]string, 501) + for i := range ceps { + ceps[i] = fmt.Sprintf("%08d", i) + } + row := func(cep string) string { + return cep + ",,,,,X,SP,São Paulo,Sudeste,1,,11,1" + } + first := make([]string, 0, cepis.MaxBatch) + for _, cep := range ceps[:cepis.MaxBatch] { + first = append(first, row(cep)) + } + api.custom = func(_ string, n int) stub { + if n == 1 { + return ok(200, csvBody(first...)) + } + return ok(200, csvBody(row(ceps[cepis.MaxBatch]))) + } + + result, err := newClient(t, api).LookupMany(context.Background(), ceps) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if got := api.totalCalls(); got != 2 { + t.Errorf("%d requests, want 2: 501 CEPs exceed the API limit of 500", got) + } + if len(result) != 501 { + t.Errorf("len = %d, want 501", len(result)) + } + if result[500] == nil || result[500].CEP != ceps[500] { + t.Errorf("result[500] = %+v", result[500]) + } +} + +func TestBatchTruncatedResponseDoesNotShift(t *testing.T) { + api := newFakeAPI(t) + body := csvBody("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1") + api.respond("/csv?ceps=01001000%2C20040020", ok(200, body)) + + result, err := newClient(t, api).LookupMany( + context.Background(), + []string{"01001000", "20040020"}, + ) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if len(result) != 2 { + t.Fatalf("len = %d, want 2: padding beats shifting", len(result)) + } + if result[1] != nil { + t.Errorf("result[1] = %+v, want nil", result[1]) + } +} + +func TestBatchMalformedCEPBecomesNil(t *testing.T) { + api := newFakeAPI(t) + body := csvBody( + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", + "nope,,,,,,,,,,,,", + ) + api.respond("/csv?ceps=01001000%2Cnope", ok(200, body)) + + result, err := newClient(t, api).LookupMany(context.Background(), []string{"01001000", "nope"}) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if result[1] != nil { + t.Errorf("result[1] = %+v, want nil", result[1]) + } +} + +func TestHealth(t *testing.T) { + api := newFakeAPI(t) + api.respond("/health", ok(200, `{"status":"ok","dne":"26071","ceps":1596168,`+ + `"geo":894489,"base_gerada_em":"2026-07-24T03:48:28Z"}`)) + + health, err := newClient(t, api).Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !health.OK() { + t.Error("OK = false") + } + if health.DNE != "26071" || health.CEPs != 1596168 || health.Geo != 894489 { + t.Errorf("Health = %+v", *health) + } + if health.BaseGeradaEm != "2026-07-24T03:48:28Z" { + t.Errorf("BaseGeradaEm = %q", health.BaseGeradaEm) + } +} + +func TestHealthDegradedIsNotAnError(t *testing.T) { + api := newFakeAPI(t) + api.respond("/health", ok(503, `{"status":"degradado","dne":"","ceps":0,"geo":0}`)) + + health, err := newClient(t, api).Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if health.OK() { + t.Error("OK = true, want false for a degraded API") + } + if got := api.countPrefix("/health"); got != 1 { + t.Errorf("%d requests, want 1: a reported degradation is an answer, not a failure", got) + } +} + +func TestTrailingSlashIsNotDuplicated(t *testing.T) { + client, err := cepis.New(cepis.WithBaseURL("http://example.test/")) + if err != nil { + t.Fatalf("New: %v", err) + } + if client.BaseURL() != "http://example.test" { + t.Errorf("BaseURL = %q", client.BaseURL()) + } +} + +func TestInvalidConfiguration(t *testing.T) { + cases := map[string]cepis.Option{ + "zero attempts": cepis.WithMaxAttempts(0), + "zero timeout": cepis.WithTimeout(0), + "empty base": cepis.WithBaseURL(" "), + "nil http": cepis.WithHTTPClient(nil), + } + for name, opt := range cases { + if _, err := cepis.New(opt); !errors.Is(err, cepis.ErrInvalidConfig) { + t.Errorf("%s: error = %v, want ErrInvalidConfig", name, err) + } + } +} + +func TestDefaultClientIdentifiesTheLibrary(t *testing.T) { + if agent := cepis.Default().UserAgent(); !strings.HasPrefix(agent, "cepis-go/"+cepis.Version) { + t.Errorf("User-Agent = %q", agent) + } + if cepis.Default().BaseURL() != cepis.DefaultBaseURL { + t.Errorf("BaseURL = %q", cepis.Default().BaseURL()) + } +} + +func withGeo(t *testing.T, geo string) string { + t.Helper() + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(seAddressJSON), &payload); err != nil { + t.Fatalf("fixture: %v", err) + } + payload["geo"] = json.RawMessage(geo) + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("fixture: %v", err) + } + return string(body) +} |