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 TestLookupNotFoundCarriesTheMunicipality(t *testing.T) { api := newFakeAPI(t) api.respond("/25845000", ok(404, `{"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}}`)) _, err := newClient(t, api).Lookup(context.Background(), "25845-000") var cerr *cepis.Error if !errors.As(err, &cerr) { t.Fatalf("error %v is not a *cepis.Error", err) } if cerr.Faixa == nil { t.Fatal("Faixa is nil, want the municipality of the range") } if cerr.Faixa.Localidade != "Areal" || cerr.Faixa.UF != "RJ" { t.Errorf("Faixa = %+v, want Areal/RJ", cerr.Faixa) } // The published bounds are the CEPs that exist, never the DNE's own start // — 25845000 is exactly the CEP the caller just got wrong. if cerr.Faixa.CEPIni != "25845001" || cerr.Faixa.CEPFim != "25849899" { t.Errorf("range = %s-%s, want 25845001-25849899", cerr.Faixa.CEPIni, cerr.Faixa.CEPFim) } if cerr.Faixa.CEPs != 118 { t.Errorf("CEPs = %d, want 118", cerr.Faixa.CEPs) } if !strings.Contains(cerr.Error(), "Areal/RJ") { t.Errorf("message %q does not mention the municipality", cerr.Error()) } } func TestLookupNotFoundWithoutAFaixa(t *testing.T) { // A CEP in a band the Correios gave to no municipality. The API omits the // object rather than sending null, and the error stays perfectly usable. api := newFakeAPI(t) api.respond("/06000000", ok(404, `{"erro":"cep_nao_encontrado"}`)) _, err := newClient(t, api).Lookup(context.Background(), "06000000") var cerr *cepis.Error if !errors.As(err, &cerr) || !errors.Is(err, cepis.ErrCEPNotFound) { t.Fatalf("error = %v, want ErrCEPNotFound", err) } if cerr.Faixa != nil { t.Errorf("Faixa = %+v, want nil", cerr.Faixa) } } 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, "captive portal")) _, 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) }