summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-27 21:12:05 -0300
committerMurilo Ijanc <[email protected]>2026-07-27 21:12:05 -0300
commit97b49ae7081b989274b313532ff17c71694097a9 (patch)
tree6595e0d5a39d04971a71e886bc3f8cc589741f5d
parent83ce95cf10fd9e9d581362453c558239d37ae06e (diff)
downloadtelegram-5ab522ac2ee1417f587525de291ad097205448e8.tar.gz
Read the faixa from the client instead of fetching it againHEADv0.2.1main
cep.is/go v0.2.0 exposes the municipality as Error.Faixa, which is what internal/lookup existed to work around: v0.1.0 discarded the 404 body, so the bot re-issued the request just to read the block back. The extra GET on the 404 path is gone, and with it the second timeout, the body size cap and the "is it still a 404?" check that guarded it. The replies are byte for byte what they were -- the formatting tests did not change a single assertion, only the type they build.
-rw-r--r--README.md8
-rw-r--r--go.mod2
-rw-r--r--go.sum2
-rw-r--r--internal/app/app.go9
-rw-r--r--internal/bot/bot.go8
-rw-r--r--internal/bot/format.go33
-rw-r--r--internal/bot/format_test.go29
-rw-r--r--internal/lookup/lookup.go248
-rw-r--r--internal/lookup/lookup_test.go279
9 files changed, 52 insertions, 566 deletions
diff --git a/README.md b/README.md
index aad82cf..3feea80 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/go.mod b/go.mod
index b2681be..5aae47e 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index dde6d3c..2d976c1 100644
--- a/go.sum
+++ b/go.sum
@@ -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, &notFound) {
- 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, &notFound) {
- 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())
- }
-}