diff options
| -rw-r--r-- | README.md | 8 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | internal/app/app.go | 9 | ||||
| -rw-r--r-- | internal/bot/bot.go | 8 | ||||
| -rw-r--r-- | internal/bot/format.go | 33 | ||||
| -rw-r--r-- | internal/bot/format_test.go | 29 | ||||
| -rw-r--r-- | internal/lookup/lookup.go | 248 | ||||
| -rw-r--r-- | internal/lookup/lookup_test.go | 279 |
9 files changed, 52 insertions, 566 deletions
@@ -47,11 +47,9 @@ A cidade vem no próprio 404 da API, no objeto `faixa`. Quando o CEP cai num intervalo que os Correios não deram a município nenhum, o objeto não vem e a resposta é a de sempre, sem a linha do meio. -Por enquanto isso custa um GET a mais, e só no caminho do 404: o `cep.is/go` -v0.1.0 joga fora o corpo da resposta 404, e é de lá que a faixa é lida -(`internal/lookup`). Qualquer problema nessa segunda consulta — lentidão, JSON -quebrado, faixa sem município — cai de volta na resposta de sempre. Some quando -a biblioteca expuser o campo. +Não custa requisição nenhuma a mais: quem lê o objeto é o `cep.is/go` v0.2.0, +em `Error.Faixa`. Faixa ausente ou sem município cai de volta na resposta de +sempre. ## Criando o bot @@ -2,4 +2,4 @@ module cep.is/telegrambot go 1.23 -require cep.is/go v0.0.0-20260726172041-323d9a8422f0 +require cep.is/go v0.2.0 @@ -1,2 +1,4 @@ cep.is/go v0.0.0-20260726172041-323d9a8422f0 h1:dafV2FEOBQi4/hLSn6c+8NWh4PN6s+80AB0iAyLz6JQ= cep.is/go v0.0.0-20260726172041-323d9a8422f0/go.mod h1:scg4o+dKixPVgqiEkccyZnwkNdzt1tRfdLf0bzaGAj4= +cep.is/go v0.2.0 h1:ZkZMJmybON2TINEZXWBsbSvwEFKRJ1EiLHaYPAjUVLY= +cep.is/go v0.2.0/go.mod h1:scg4o+dKixPVgqiEkccyZnwkNdzt1tRfdLf0bzaGAj4= diff --git a/internal/app/app.go b/internal/app/app.go index ccb8483..3d58dca 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,7 +15,6 @@ import ( "cep.is/telegrambot/internal/bot" "cep.is/telegrambot/internal/config" - "cep.is/telegrambot/internal/lookup" "cep.is/telegrambot/internal/telegram" ) @@ -35,11 +34,6 @@ type App struct { // The whole-call timeout of the cep.is client is the shutdown grace on purpose: // handling one update is what a termination signal waits for, so a lookup must // not be allowed to outlast that budget. -// -// The client goes to the bot wrapped in [lookup.Client], which is what keeps the -// municipality the API names on a 404. The wrapper has its own short timeout, -// deliberately far below the grace: it is a footnote on an answer that is -// already writable. func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) { if logger == nil { logger = slog.Default() @@ -56,7 +50,6 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err if err != nil { return nil, fmt.Errorf("app: cep.is client: %w", err) } - lookups := lookup.New(cepisClient) api, err := telegram.New(cfg.Token, telegram.WithBaseURL(cfg.TelegramAPIBaseURL), @@ -76,7 +69,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err return nil, fmt.Errorf("app: getMe returned no username") } - handler := bot.New(lookups, api, me.Username, bot.WithLogger(logger)) + handler := bot.New(cepisClient, api, me.Username, bot.WithLogger(logger)) poller := telegram.NewPoller(api, handler, telegram.WithLogger(logger), telegram.WithPollTimeout(cfg.PollTimeout), diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 4abf02c..b361841 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -13,15 +13,13 @@ import ( cepis "cep.is/go" - "cep.is/telegrambot/internal/lookup" "cep.is/telegrambot/internal/telegram" ) // Lookups is the subset of the cep.is client this bot needs. Depending on the // interface rather than the concrete client keeps the seam narrow and visible. // -// A failure may be a [*lookup.NotFoundError], which still matches -// cepis.ErrCEPNotFound and additionally names the municipality the CEP would +// A failure may be a [*cepis.Error] carrying the municipality the CEP would // belong to; nothing here has to know that, because [ErrorMessage] reads it. type Lookups interface { Lookup(ctx context.Context, cep string) (*cepis.Address, error) @@ -273,8 +271,8 @@ func inlineErrorTitle(err error) string { // Titles and descriptions are plain text for Telegram, so nothing here is // escaped: HTML applies to the message body alone. func inlineErrorDescription(err error) string { - if faixa := lookup.FaixaOf(err); faixa != nil { - return "Cairia em " + faixa.Place() + " — toque para ver" + if faixa := faixaOf(err); faixa != nil { + return "Cairia em " + place(faixa) + " — toque para ver" } return "Toque para enviar o motivo" } diff --git a/internal/bot/format.go b/internal/bot/format.go index b94dee4..86a0ac9 100644 --- a/internal/bot/format.go +++ b/internal/bot/format.go @@ -9,8 +9,6 @@ import ( "time" cepis "cep.is/go" - - "cep.is/telegrambot/internal/lookup" ) // ParseMode is the formatting Telegram applies to every message this bot sends. @@ -197,14 +195,14 @@ const ( // CEPs are spread over nearly five thousand numbers — so its ends are the // extremes of what exists, not a list of what is valid, and the reply says so. func notFoundMessage(err error) string { - faixa := lookup.FaixaOf(err) + faixa := faixaOf(err) if faixa == nil || faixa.Localidade == "" { return notFoundHead + "\n" + notFoundAdvice } var b strings.Builder b.WriteString(notFoundHead + "\n") - b.WriteString("Ele cairia em <b>" + esc(faixa.Place()) + "</b>") + b.WriteString("Ele cairia em <b>" + esc(place(faixa)) + "</b>") count := faixaCount(faixa) span := faixaSpan(faixa) @@ -225,10 +223,33 @@ func notFoundMessage(err error) string { return b.String() } +// faixaOf returns the municipality the API named on a 404, or nil when it named +// none. Any error is safe to pass, including nil: only a *cepis.Error carrying +// a faixa yields one. +func faixaOf(err error) *cepis.Faixa { + var cerr *cepis.Error + if errors.As(err, &cerr) { + return cerr.Faixa + } + return nil +} + +// place is the municipality as it is written to a user: "Areal/RJ", or just +// "Areal" when the API sent no UF. +func place(faixa *cepis.Faixa) string { + if faixa == nil { + return "" + } + if faixa.UF == "" { + return faixa.Localidade + } + return faixa.Localidade + "/" + faixa.UF +} + // faixaCount renders how many CEPs the municipality has, or "" when the API did // not say. Without it the two ends of the range read as a count of one big // block, which is the misreading the number exists to prevent. -func faixaCount(faixa *lookup.Faixa) string { +func faixaCount(faixa *cepis.Faixa) string { switch { case faixa.CEPs <= 0: return "" @@ -241,7 +262,7 @@ func faixaCount(faixa *lookup.Faixa) string { // faixaSpan renders the ends of the range, or "" when they are missing or are // the same number — "de X a X" is not a range, and printing a single CEP there // would hand the user a guess to try. -func faixaSpan(faixa *lookup.Faixa) string { +func faixaSpan(faixa *cepis.Faixa) string { first, last := maskCEP(faixa.CEPIni), maskCEP(faixa.CEPFim) if first == "" || last == "" || first == last { return "" diff --git a/internal/bot/format_test.go b/internal/bot/format_test.go index 513db36..8e10b3e 100644 --- a/internal/bot/format_test.go +++ b/internal/bot/format_test.go @@ -7,8 +7,6 @@ import ( "testing" cepis "cep.is/go" - - "cep.is/telegrambot/internal/lookup" ) func decodeAddress(t *testing.T, body string) *cepis.Address { @@ -118,11 +116,14 @@ func TestErrorMessagesAreDistinct(t *testing.T) { } } -func notFound(faixa *lookup.Faixa) error { - return &lookup.NotFoundError{Faixa: faixa, Err: cepis.ErrCEPNotFound} +// notFound builds the error the client library returns for a CEP absent from +// the DNE, with the municipality the API named on the 404 — or without one, +// for a CEP in a band no municipality owns. +func notFound(faixa *cepis.Faixa) error { + return &cepis.Error{Kind: cepis.ErrCEPNotFound, Faixa: faixa} } -var areal = &lookup.Faixa{ +var areal = &cepis.Faixa{ Localidade: "Areal", UF: "RJ", Estado: "Rio de Janeiro", Regiao: "Sudeste", IBGE: "3300225", CEPIni: "25845001", CEPFim: "25849899", CEPs: 118, } @@ -168,7 +169,7 @@ func TestNotFoundWithoutFaixaIsUnchanged(t *testing.T) { for name, err := range map[string]error{ "plain sentinel": cepis.ErrCEPNotFound, "no faixa": notFound(nil), - "empty faixa": notFound(&lookup.Faixa{}), + "empty faixa": notFound(&cepis.Faixa{}), } { if got := ErrorMessage(err); got != want { t.Fatalf("%s:\n got %q\nwant %q", name, got, want) @@ -177,7 +178,7 @@ func TestNotFoundWithoutFaixaIsUnchanged(t *testing.T) { } func TestNotFoundEscapesTheMunicipality(t *testing.T) { - text := ErrorMessage(notFound(&lookup.Faixa{ + text := ErrorMessage(notFound(&cepis.Faixa{ Localidade: "Santa <b>Rita</b> & Cia", UF: "M&G", CEPIni: "25845001", CEPFim: "25849899", CEPs: 2, })) @@ -194,32 +195,32 @@ func TestNotFoundEscapesTheMunicipality(t *testing.T) { // take the sentence down with it. func TestNotFoundDegradesFieldByField(t *testing.T) { cases := map[string]struct { - faixa *lookup.Faixa + faixa *cepis.Faixa present []string absent []string }{ "no count": { - faixa: &lookup.Faixa{Localidade: "Areal", UF: "RJ", CEPIni: "25845001", CEPFim: "25849899"}, + faixa: &cepis.Faixa{Localidade: "Areal", UF: "RJ", CEPIni: "25845001", CEPFim: "25849899"}, present: []string{"Areal/RJ", "25845-001", "25849-899", "esparsa"}, absent: []string{"CEPs,"}, }, "no range": { - faixa: &lookup.Faixa{Localidade: "Areal", UF: "RJ", CEPs: 118}, + faixa: &cepis.Faixa{Localidade: "Areal", UF: "RJ", CEPs: 118}, present: []string{"Areal/RJ", "118 CEPs"}, absent: []string{"<code>", "esparsa"}, }, "only the name": { - faixa: &lookup.Faixa{Localidade: "Areal"}, + faixa: &cepis.Faixa{Localidade: "Areal"}, present: []string{"cairia em <b>Areal</b>."}, absent: []string{"<code>", "esparsa", "Areal/"}, }, "single CEP": { - faixa: &lookup.Faixa{Localidade: "Vila", UF: "SP", CEPIni: "12345678", CEPFim: "12345678", CEPs: 1}, + faixa: &cepis.Faixa{Localidade: "Vila", UF: "SP", CEPIni: "12345678", CEPFim: "12345678", CEPs: 1}, present: []string{"1 CEP."}, absent: []string{"12345-678"}, }, "unusable range": { - faixa: &lookup.Faixa{Localidade: "Areal", UF: "RJ", CEPIni: "abc", CEPFim: "2584", CEPs: 118}, + faixa: &cepis.Faixa{Localidade: "Areal", UF: "RJ", CEPIni: "abc", CEPFim: "2584", CEPs: 118}, present: []string{"118 CEPs."}, absent: []string{"abc", "2584"}, }, @@ -251,7 +252,7 @@ func TestInlineErrorDescriptionNamesTheMunicipality(t *testing.T) { } // Titles and descriptions are plain text: escaping them would show the // entities to the user. - got := inlineErrorDescription(notFound(&lookup.Faixa{Localidade: "Santa & Cia", UF: "SP"})) + got := inlineErrorDescription(notFound(&cepis.Faixa{Localidade: "Santa & Cia", UF: "SP"})) if !strings.Contains(got, "Santa & Cia/SP") { t.Fatalf("description = %q", got) } diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go deleted file mode 100644 index 956a731..0000000 --- a/internal/lookup/lookup.go +++ /dev/null @@ -1,248 +0,0 @@ -// Package lookup resolves a CEP through the cep.is API. -// -// It is a thin layer over the official client, and it exists for one thing the -// client does not do: a 404 from GET /{cep} may carry a `faixa` block naming -// the municipality that CEP would belong to, and cep.is/go v0.1.0 turns the -// response into [cepis.ErrCEPNotFound] without keeping anything from the body. -// This package puts that block back, as [NotFoundError.Faixa]. -// -// Everything about it is best-effort. The municipality is context on top of an -// error that is already complete without it, so every failure along the way — -// no body, malformed JSON, a slow second request — ends as a plain -// ErrCEPNotFound, exactly as if the API had never sent the block. -package lookup - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - "time" - - cepis "cep.is/go" -) - -// defaultFaixaTimeout caps the extra request. It is deliberately short and -// deliberately not the timeout of the lookup itself: the CEP is already known -// not to exist, the answer is already writable, and nobody should wait longer -// for the footnote than for the answer. -const defaultFaixaTimeout = 2 * time.Second - -// maxFaixaBody is how much of the 404 body is read. The real one is around 200 -// bytes; anything past this is not the API answering. -const maxFaixaBody = 64 << 10 - -// Faixa is the municipality a CEP that does not exist would fall in, together -// with the range of CEPs that municipality actually has. -// -// A CEP that fails is rarely a random number: it is usually someone who knows -// the city and guessed the rest, and the classic guess is the "-000" that 957 -// municipalities never received. Areal/RJ answers from 25845-001 up and never -// at 25845-000, so the 404 for 25845000 names Areal. -// -// CEPIni and CEPFim are the smallest and the largest CEP that EXIST there, not -// the bounds the Correios publish — the DNE's own start for Areal is the -// 25845000 that just failed. The range is sparse, which is what CEPs is for: -// Areal's 118 CEPs are scattered over nearly five thousand numbers. Never -// present the pair as a list of valid CEPs. -type Faixa struct { - Localidade string `json:"localidade"` - UF string `json:"uf"` - Estado string `json:"estado"` - Regiao string `json:"regiao"` - IBGE string `json:"ibge"` - CEPIni string `json:"cep_ini"` - CEPFim string `json:"cep_fim"` - - // CEPs is how many CEPs the municipality has inside the range. - CEPs int `json:"ceps"` -} - -// Place is the municipality as it is written to a user: "Areal/RJ", or just -// "Areal" when the API sent no UF. -func (f *Faixa) Place() string { - if f == nil { - return "" - } - if f.UF == "" { - return f.Localidade - } - return f.Localidade + "/" + f.UF -} - -// NotFoundError is [cepis.ErrCEPNotFound] with the municipality attached. It -// matches the sentinel through errors.Is, so a caller that does not know about -// this type keeps behaving exactly as before: -// -// if errors.Is(err, cepis.ErrCEPNotFound) { ... } -type NotFoundError struct { - // Faixa is the municipality the CEP would belong to. It is never nil in - // an error this package builds: without a faixa there is nothing to add - // and the underlying error is returned untouched. - Faixa *Faixa - - // Err is the error from the client library, wrapped unchanged. - Err error -} - -// Error implements the error interface, reporting the wrapped failure verbatim: -// the faixa is for the user, not for the log line. -func (e *NotFoundError) Error() string { - if e.Err != nil { - return e.Err.Error() - } - return cepis.ErrCEPNotFound.Error() -} - -// Unwrap keeps errors.Is(err, cepis.ErrCEPNotFound) true. -func (e *NotFoundError) Unwrap() error { - if e.Err != nil { - return e.Err - } - return cepis.ErrCEPNotFound -} - -// FaixaOf returns the municipality carried by err, or nil when there is none. -// Any error is safe to pass, including nil. -func FaixaOf(err error) *Faixa { - var notFound *NotFoundError - if errors.As(err, ¬Found) { - return notFound.Faixa - } - return nil -} - -// Client resolves CEPs. It is safe for concurrent use, as the client it wraps -// is. -type Client struct { - api *cepis.Client - httpClient *http.Client - timeout time.Duration -} - -// Option configures a [Client]. -type Option func(*Client) - -// WithTimeout caps the extra request that reads the faixa off a 404. -func WithTimeout(timeout time.Duration) Option { - return func(c *Client) { - if timeout > 0 { - c.timeout = timeout - } - } -} - -// WithHTTPClient supplies the [net/http.Client] used for that request. -func WithHTTPClient(httpClient *http.Client) Option { - return func(c *Client) { - if httpClient != nil { - c.httpClient = httpClient - } - } -} - -// New wraps an already configured cep.is client. -func New(api *cepis.Client, opts ...Option) *Client { - c := &Client{ - api: api, - httpClient: &http.Client{}, - timeout: defaultFaixaTimeout, - } - for _, opt := range opts { - if opt != nil { - opt(c) - } - } - return c -} - -// Lookup resolves a single CEP. -// -// It behaves exactly like [cepis.Client.Lookup], except that a CEP absent from -// the DNE may come back as a [*NotFoundError] naming the municipality. That is -// still a failure and still matches cepis.ErrCEPNotFound; nothing about it says -// an address was found. -func (c *Client) Lookup(ctx context.Context, cep string) (*cepis.Address, error) { - address, err := c.api.Lookup(ctx, cep) - if err == nil || !errors.Is(err, cepis.ErrCEPNotFound) { - return address, err - } - - faixa := c.faixa(ctx, cep) - if faixa == nil { - return nil, err - } - return nil, &NotFoundError{Faixa: faixa, Err: err} -} - -// faixa asks for the CEP once more, only to read the block the library dropped. -// -// One extra GET is the price of not forking the client. It is bounded to the -// 404 path, which is definitive and never retried, so the request cannot land -// in a retry loop; and it returns nil for every problem it meets, because the -// caller already has a complete answer to send without it. -// -// Delete this the day cep.is/go exposes the faixa itself. -func (c *Client) faixa(ctx context.Context, cep string) *Faixa { - number, err := cepis.NormalizeCEP(cep) - if err != nil { - return nil - } - - ctx, cancel := context.WithTimeout(ctx, c.timeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.api.BaseURL()+"/"+number, nil) - if err != nil { - return nil - } - req.Header.Set("User-Agent", c.api.UserAgent()) - req.Header.Set("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil - } - defer resp.Body.Close() - - // Anything but a second 404 means the answer changed under us — a CEP - // that now exists, an outage, a captive portal. None of those is a - // municipality. - if resp.StatusCode != http.StatusNotFound { - return nil - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, maxFaixaBody)) - if err != nil { - return nil - } - return decodeFaixa(body) -} - -// decodeFaixa reads the `faixa` block of a 404 body. Every failure yields nil: -// an absent block (the API omits it, never sends null), a body that is not -// JSON, a block that names no municipality. A field the API grows later is -// ignored rather than fatal, which is what encoding/json does by default. -func decodeFaixa(body []byte) *Faixa { - if len(body) == 0 { - return nil - } - - var envelope struct { - Faixa *Faixa `json:"faixa"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return nil - } - - faixa := envelope.Faixa - if faixa == nil || strings.TrimSpace(faixa.Localidade) == "" { - return nil - } - if faixa.CEPs < 0 { - faixa.CEPs = 0 - } - return faixa -} diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go deleted file mode 100644 index 6eb7b4e..0000000 --- a/internal/lookup/lookup_test.go +++ /dev/null @@ -1,279 +0,0 @@ -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()) - } -} |