From 83ce95cf10fd9e9d581362453c558239d37ae06e Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:46:40 -0300 Subject: Name the municipality when the CEP does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answering only "não encontrei" wastes the trip. A CEP that fails is almost never a random number: it is someone who knows the city and guessed the rest, most often at the "-000" that 957 municipalities never received. The API now names that municipality on the 404, so the reply names it too. It stays an answer to a failure. No 📮, no address block, and no CEP suggested from inside the range — the range is sparse (Areal's 118 CEPs span nearly five thousand numbers), so the reply gives the count and says so instead of letting the two ends read as a list of valid CEPs. cep.is/go v0.1.0 discards the 404 body, so internal/lookup reads it with one extra GET, on that path alone and never inside a retry. Everything that can go wrong there — a slow answer, broken JSON, a faixa naming no municipality — falls back to the reply the bot has always given. The wrapper goes away the day the library exposes the field. --- internal/app/app.go | 11 +- internal/app/app_test.go | 50 ++++++++ internal/app/fakes_test.go | 20 ++- internal/bot/bot.go | 21 +++- internal/bot/format.go | 96 +++++++++++++- internal/bot/format_test.go | 141 +++++++++++++++++++++ internal/lookup/lookup.go | 248 ++++++++++++++++++++++++++++++++++++ internal/lookup/lookup_test.go | 279 +++++++++++++++++++++++++++++++++++++++++ internal/telegram/client.go | 2 +- 9 files changed, 860 insertions(+), 8 deletions(-) create mode 100644 internal/lookup/lookup.go create mode 100644 internal/lookup/lookup_test.go (limited to 'internal') diff --git a/internal/app/app.go b/internal/app/app.go index 0b78baf..ccb8483 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,6 +15,7 @@ import ( "cep.is/telegrambot/internal/bot" "cep.is/telegrambot/internal/config" + "cep.is/telegrambot/internal/lookup" "cep.is/telegrambot/internal/telegram" ) @@ -34,22 +35,28 @@ 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() } - lookups, err := cepis.New( + cepisClient, err := cepis.New( cepis.WithBaseURL(cfg.CepisBaseURL), cepis.WithTimeout(cfg.RequestTimeout), cepis.WithMaxAttempts(cfg.MaxAttempts), cepis.WithBackoff(cfg.BackoffBase, cfg.BackoffMax), cepis.WithTotalTimeout(cfg.ShutdownGrace), - cepis.WithUserAgent("cepis-telegram/0.1.0 (+https://cep.is)"), + cepis.WithUserAgent("cepis-telegram/0.2.0 (+https://cep.is)"), ) 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), diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e770060..d430e11 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -291,6 +291,8 @@ func TestMalformedCEP(t *testing.T) { } } +// A CEP in a numeric band the Correios never handed to any municipality: the +// 404 carries no faixa, and the answer is the one the bot has always given. func TestCEPNotFound(t *testing.T) { tg, api := newFakeTelegram(t), newFakeCEPIS(t) tg.push(privateMessage(1, 555, "99999999")) @@ -300,6 +302,54 @@ func TestCEPNotFound(t *testing.T) { if !strings.Contains(sent[0].Text, "não encontrado") { t.Fatalf("reply = %q, want a not-found message", sent[0].Text) } + if strings.Contains(sent[0].Text, "cairia em") { + t.Fatalf("named a municipality the API did not send: %q", sent[0].Text) + } +} + +// The "-000" guess for Areal/RJ, which never received that number. The reply +// still says the CEP does not exist, and adds where it would have been. +func TestCEPNotFoundNamesTheMunicipality(t *testing.T) { + tg, api := newFakeTelegram(t), newFakeCEPIS(t) + tg.push(privateMessage(1, 555, "25845-000")) + start(t, testConfig(tg, api)) + + sent := waitForMessages(t, tg, 1) + text := sent[0].Text + for _, needed := range []string{ + "não encontrado", "Areal/RJ", "118 CEPs", "25845-001", "25849-899", "esparsa", + } { + if !strings.Contains(text, needed) { + t.Fatalf("reply is missing %q: %q", needed, text) + } + } + if strings.Contains(text, "📮") { + t.Fatalf("a failure must not be formatted as a hit: %q", text) + } + if strings.Contains(text, "25845-000") { + t.Fatalf("the reply hands back the CEP that just failed: %q", text) + } +} + +func TestInlineNotFoundNamesTheMunicipality(t *testing.T) { + tg, api := newFakeTelegram(t), newFakeCEPIS(t) + tg.push(inlineQuery(1, 555, "25845000")) + start(t, testConfig(tg, api)) + + waitFor(t, "inline answer", 10*time.Second, func() bool { return len(tg.inlineAnswers()) >= 1 }) + results := tg.inlineAnswers()[0].Results + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].Title != "CEP não encontrado" { + t.Fatalf("title = %q", results[0].Title) + } + if !strings.Contains(results[0].Description, "Areal/RJ") { + t.Fatalf("description = %q, want the municipality named", results[0].Description) + } + if !strings.Contains(results[0].InputMessageContent.MessageText, "118 CEPs") { + t.Fatalf("message = %q", results[0].InputMessageContent.MessageText) + } } func TestAPIServerError(t *testing.T) { diff --git a/internal/app/fakes_test.go b/internal/app/fakes_test.go index 011d4af..9281a53 100644 --- a/internal/app/fakes_test.go +++ b/internal/app/fakes_test.go @@ -182,12 +182,16 @@ func (f *fakeTelegram) pendingCount() int { // fakeCEPIS is a real HTTP server speaking the cep.is native contract, with // enough of it to exercise every branch the bot has: an address with a street -// coordinate, one that is a whole municipality, a 404, a 400 and a 500. +// coordinate, one that is a whole municipality, a 404 that names the +// municipality the CEP would fall in, a 404 that does not, a 400 and a 500. type fakeCEPIS struct { server *httptest.Server - mu sync.Mutex + mu sync.Mutex + // bodies are the 200s, missing are the 404s that carry a faixa, and + // statuses forces a status for a CEP regardless of the other two. bodies map[string]string + missing map[string]string statuses map[string]int requests int } @@ -208,6 +212,14 @@ func newFakeCEPIS(t *testing.T) *fakeCEPIS { `"unidade":"","bairro":"Centro","localidade":"Linhares","uf":"ES","estado":"Espírito Santo",` + `"regiao":"Sudeste","ibge":"3203205","gia":"","ddd":"27","siafi":"5665"}`, }, + // 25845000 is the CEP the "-000" guess produces for Areal/RJ, which + // is coded street by street and never received that number: the + // municipality exists, the CEP does not. + missing: map[string]string{ + "25845000": `{"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}}`, + }, statuses: map[string]int{ "50000000": http.StatusInternalServerError, }, @@ -225,6 +237,7 @@ func (f *fakeCEPIS) handle(w http.ResponseWriter, r *http.Request) { cep := strings.TrimPrefix(r.URL.Path, "/") status, forced := f.statuses[cep] body, known := f.bodies[cep] + absent, attributed := f.missing[cep] f.mu.Unlock() w.Header().Set("Content-Type", "application/json; charset=utf-8") @@ -235,6 +248,9 @@ func (f *fakeCEPIS) handle(w http.ResponseWriter, r *http.Request) { case known: w.WriteHeader(http.StatusOK) w.Write([]byte(body)) + case attributed: + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(absent)) case len(cep) != 8: w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}`)) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index b29dc36..4abf02c 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -13,11 +13,16 @@ 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 +// belong to; nothing here has to know that, because [ErrorMessage] reads it. type Lookups interface { Lookup(ctx context.Context, cep string) (*cepis.Address, error) } @@ -211,7 +216,7 @@ func (b *Bot) handleInline(ctx context.Context, query *telegram.InlineQuery) err if err != nil { b.logger.Info("inline lookup failed", slog.String("error", err.Error())) return b.answerInline(ctx, query, []telegram.InlineQueryResult{ - b.inlineNotice(query, inlineErrorTitle(err), "Toque para enviar o motivo", ErrorMessage(err)), + b.inlineNotice(query, inlineErrorTitle(err), inlineErrorDescription(err), ErrorMessage(err)), }, inlineCacheOther) } @@ -260,6 +265,20 @@ func inlineErrorTitle(err error) string { return "Não consegui consultar agora" } +// inlineErrorDescription is the second line of the notice. Naming the +// municipality here is what lets someone see, in the results list, that the +// city they had in mind is right and the number is not — before sending +// anything to the chat. +// +// 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" + } + return "Toque para enviar o motivo" +} + // rateKey is the token bucket's key: the author when there is one, and the chat // otherwise. A channel post has no author, and channel ids are negative, so the // two spaces cannot collide. diff --git a/internal/bot/format.go b/internal/bot/format.go index 9a9a22d..b94dee4 100644 --- a/internal/bot/format.go +++ b/internal/bot/format.go @@ -9,6 +9,8 @@ import ( "time" cepis "cep.is/go" + + "cep.is/telegrambot/internal/lookup" ) // ParseMode is the formatting Telegram applies to every message this bot sends. @@ -152,8 +154,7 @@ func formatRadius(metres int) string { func ErrorMessage(err error) string { switch { case errors.Is(err, cepis.ErrCEPNotFound): - return "🔍 CEP não encontrado na base dos Correios (DNE).\n" + - "Confira os oito dígitos — a base é atualizada a cada quinze dias." + return notFoundMessage(err) case errors.Is(err, cepis.ErrInvalidCEP): return "✋ CEP inválido. Preciso de oito dígitos: 01001000 " + "ou 01001-000." @@ -173,6 +174,97 @@ func ErrorMessage(err error) string { return "⚠️ Não consegui consultar este CEP agora. Tente de novo em alguns minutos." } +// The two halves of the not-found answer, unchanged from the day the bot +// shipped. Whatever is said about the municipality goes between them: the reply +// opens by saying the CEP does not exist and closes by saying what to do, so +// the middle cannot be mistaken for an address. +const ( + notFoundHead = "🔍 CEP não encontrado na base dos Correios (DNE)." + notFoundAdvice = "Confira os oito dígitos — a base é atualizada a cada quinze dias." +) + +// notFoundMessage answers a CEP that does not exist, naming the municipality it +// would belong to when the API told us which one. +// +// It is worth the extra lines. The CEP that fails is almost never a random +// number — it is someone who knows the city and guessed the rest, most often at +// the "-000" that 957 municipalities never received. Telling that person only +// "não encontrei" wastes the trip; telling them the number would have been in +// Areal/RJ says which half of their guess was right. +// +// What it must never do is read like a hit. There is no 📮, no address block +// and no CEP suggested from inside the range: the range is sparse — Areal's 118 +// 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) + if faixa == nil || faixa.Localidade == "" { + return notFoundHead + "\n" + notFoundAdvice + } + + var b strings.Builder + b.WriteString(notFoundHead + "\n") + b.WriteString("Ele cairia em " + esc(faixa.Place()) + "") + + count := faixaCount(faixa) + span := faixaSpan(faixa) + switch { + case count != "" && span != "": + b.WriteString(", que tem " + count + ", " + span + ".\n") + b.WriteString("A faixa é esparsa: nem todo número entre os dois é um CEP válido.\n") + case count != "": + b.WriteString(", que tem " + count + ".\n") + case span != "": + b.WriteString(", com CEPs " + span + ".\n") + b.WriteString("A faixa é esparsa: nem todo número entre os dois é um CEP válido.\n") + default: + b.WriteString(".\n") + } + + b.WriteString(notFoundAdvice) + return b.String() +} + +// 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 { + switch { + case faixa.CEPs <= 0: + return "" + case faixa.CEPs == 1: + return "1 CEP" + } + return strconv.Itoa(faixa.CEPs) + " CEPs" +} + +// 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 { + first, last := maskCEP(faixa.CEPIni), maskCEP(faixa.CEPFim) + if first == "" || last == "" || first == last { + return "" + } + return "de " + esc(first) + " a " + esc(last) + "" +} + +// maskCEP puts the mask on eight digits, and returns "" for anything else: a +// CEP the API sent in a shape this bot does not recognise is left out of the +// sentence rather than printed raw. +func maskCEP(cep string) string { + cep = strings.TrimSpace(cep) + if len(cep) != 8 { + return "" + } + for _, r := range cep { + if r < '0' || r > '9' { + return "" + } + } + return cep[:5] + "-" + cep[5:] +} + // RateLimitMessage is what a user held by the local token bucket is told, once // per warning window. func RateLimitMessage(retryAfter time.Duration) string { diff --git a/internal/bot/format_test.go b/internal/bot/format_test.go index 56b8dda..513db36 100644 --- a/internal/bot/format_test.go +++ b/internal/bot/format_test.go @@ -7,6 +7,8 @@ import ( "testing" cepis "cep.is/go" + + "cep.is/telegrambot/internal/lookup" ) func decodeAddress(t *testing.T, body string) *cepis.Address { @@ -116,6 +118,145 @@ func TestErrorMessagesAreDistinct(t *testing.T) { } } +func notFound(faixa *lookup.Faixa) error { + return &lookup.NotFoundError{Faixa: faixa, Err: cepis.ErrCEPNotFound} +} + +var areal = &lookup.Faixa{ + Localidade: "Areal", UF: "RJ", Estado: "Rio de Janeiro", Regiao: "Sudeste", + IBGE: "3300225", CEPIni: "25845001", CEPFim: "25849899", CEPs: 118, +} + +func TestNotFoundNamesTheMunicipality(t *testing.T) { + text := ErrorMessage(notFound(areal)) + + for _, needed := range []string{ + "não encontrado", "Areal/RJ", "118 CEPs", "25845-001", "25849-899", + "esparsa", "Confira os oito dígitos", + } { + if !strings.Contains(text, needed) { + t.Fatalf("reply is missing %q:\n%s", needed, text) + } + } +} + +// The municipality is context on a failure. A reply that looks like a hit would +// have someone write the city down as the answer. +func TestNotFoundDoesNotLookLikeAnAnswer(t *testing.T) { + text := ErrorMessage(notFound(areal)) + + if strings.Contains(text, "📮") { + t.Fatalf("the found-address marker leaked into a failure:\n%s", text) + } + if !strings.HasPrefix(text, notFoundHead) { + t.Fatalf("the reply must open by saying the CEP does not exist:\n%s", text) + } + if !strings.HasSuffix(text, notFoundAdvice) { + t.Fatalf("the reply must close with the usual advice:\n%s", text) + } + if strings.Contains(text, "25845-000") { + t.Fatalf("the reply suggests the CEP that just failed:\n%s", text) + } +} + +// Absence of a faixa must be indistinguishable from the day before this +// existed, byte for byte. +func TestNotFoundWithoutFaixaIsUnchanged(t *testing.T) { + const want = "🔍 CEP não encontrado na base dos Correios (DNE).\n" + + "Confira os oito dígitos — a base é atualizada a cada quinze dias." + + for name, err := range map[string]error{ + "plain sentinel": cepis.ErrCEPNotFound, + "no faixa": notFound(nil), + "empty faixa": notFound(&lookup.Faixa{}), + } { + if got := ErrorMessage(err); got != want { + t.Fatalf("%s:\n got %q\nwant %q", name, got, want) + } + } +} + +func TestNotFoundEscapesTheMunicipality(t *testing.T) { + text := ErrorMessage(notFound(&lookup.Faixa{ + Localidade: "Santa Rita & Cia", UF: "M&G", + CEPIni: "25845001", CEPFim: "25849899", CEPs: 2, + })) + + if strings.Contains(text, "Rita") { + t.Fatalf("the municipality name must be escaped:\n%s", text) + } + if !strings.Contains(text, "Santa <b>Rita</b> & Cia/M&G") { + t.Fatalf("escaping is wrong:\n%s", text) + } +} + +// Every field of the faixa is optional in practice, and a missing one may not +// take the sentence down with it. +func TestNotFoundDegradesFieldByField(t *testing.T) { + cases := map[string]struct { + faixa *lookup.Faixa + present []string + absent []string + }{ + "no count": { + faixa: &lookup.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}, + present: []string{"Areal/RJ", "118 CEPs"}, + absent: []string{"", "esparsa"}, + }, + "only the name": { + faixa: &lookup.Faixa{Localidade: "Areal"}, + present: []string{"cairia em Areal."}, + absent: []string{"", "esparsa", "Areal/"}, + }, + "single CEP": { + faixa: &lookup.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}, + present: []string{"118 CEPs."}, + absent: []string{"abc", "2584"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + text := ErrorMessage(notFound(tc.faixa)) + for _, needed := range tc.present { + if !strings.Contains(text, needed) { + t.Fatalf("missing %q:\n%s", needed, text) + } + } + for _, unwanted := range tc.absent { + if strings.Contains(text, unwanted) { + t.Fatalf("should not carry %q:\n%s", unwanted, text) + } + } + }) + } +} + +func TestInlineErrorDescriptionNamesTheMunicipality(t *testing.T) { + if got := inlineErrorDescription(notFound(areal)); got != "Cairia em Areal/RJ — toque para ver" { + t.Fatalf("description = %q", got) + } + if got := inlineErrorDescription(cepis.ErrCEPNotFound); got != "Toque para enviar o motivo" { + t.Fatalf("description without a faixa = %q", got) + } + // 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"})) + if !strings.Contains(got, "Santa & Cia/SP") { + t.Fatalf("description = %q", got) + } +} + func TestHelpMentionsEveryEntryPoint(t *testing.T) { text := HelpMessage("cepisbot") for _, needed := range []string{"01001-000", "/cep 01001000", "@cepisbot 01001000", "https://cep.is"} { diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go new file mode 100644 index 0000000..956a731 --- /dev/null +++ b/internal/lookup/lookup.go @@ -0,0 +1,248 @@ +// 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 new file mode 100644 index 0000000..6eb7b4e --- /dev/null +++ b/internal/lookup/lookup_test.go @@ -0,0 +1,279 @@ +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": `captive portal`, + "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()) + } +} diff --git a/internal/telegram/client.go b/internal/telegram/client.go index 1b9be78..8d935e0 100644 --- a/internal/telegram/client.go +++ b/internal/telegram/client.go @@ -24,7 +24,7 @@ import ( // DefaultBaseURL is the public Bot API host. const DefaultBaseURL = "https://api.telegram.org" -const userAgent = "cepis-telegram/0.1.0 (+https://cep.is)" +const userAgent = "cepis-telegram/0.2.0 (+https://cep.is)" // Error is a failure reported by the Bot API itself, as opposed to a transport // failure. Code is the Bot API error_code, which mirrors an HTTP status. -- cgit v1.3