summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
6 files changed, 46 insertions, 560 deletions
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())
- }
-}