summaryrefslogtreecommitdiffstats
path: root/cepis_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'cepis_test.go')
-rw-r--r--cepis_test.go694
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)
+}