summaryrefslogtreecommitdiffstats
path: root/internal/app
diff options
context:
space:
mode:
Diffstat (limited to 'internal/app')
-rw-r--r--internal/app/app.go96
-rw-r--r--internal/app/app_test.go510
-rw-r--r--internal/app/fakes_test.go285
3 files changed, 891 insertions, 0 deletions
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..0b78baf
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,96 @@
+// Package app wires the configuration, the cep.is client, the Telegram client
+// and the bot into a single runnable unit.
+//
+// It exists so that main stays a thin shell around signal handling, and so the
+// test suite can run the whole thing against a fake Telegram and a fake cep.is
+// without going through the process environment.
+package app
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ cepis "cep.is/go"
+
+ "cep.is/telegrambot/internal/bot"
+ "cep.is/telegrambot/internal/config"
+ "cep.is/telegrambot/internal/telegram"
+)
+
+// App is one configured bot process.
+type App struct {
+ poller *telegram.Poller
+ username string
+ logger *slog.Logger
+}
+
+// New builds the application and identifies the bot.
+//
+// The getMe call is not a formality. It fails fast on a bad token, and it is the
+// only way to learn the username the operator chose with @BotFather, which the
+// help text quotes and which "/cep@username" has to match.
+//
+// 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.
+func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ lookups, 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)"),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("app: cep.is client: %w", err)
+ }
+
+ api, err := telegram.New(cfg.Token,
+ telegram.WithBaseURL(cfg.TelegramAPIBaseURL),
+ telegram.WithTimeout(cfg.RequestTimeout),
+ telegram.WithMaxAttempts(cfg.MaxAttempts),
+ telegram.WithBackoff(cfg.BackoffBase, cfg.BackoffMax),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("app: telegram client: %w", err)
+ }
+
+ me, err := api.GetMe(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("app: getMe: %w", err)
+ }
+ if me.Username == "" {
+ return nil, fmt.Errorf("app: getMe returned no username")
+ }
+
+ handler := bot.New(lookups, api, me.Username, bot.WithLogger(logger))
+ poller := telegram.NewPoller(api, handler,
+ telegram.WithLogger(logger),
+ telegram.WithPollTimeout(cfg.PollTimeout),
+ telegram.WithShutdownGrace(cfg.ShutdownGrace),
+ telegram.WithErrorBackoff(cfg.BackoffBase, cfg.BackoffMax),
+ )
+
+ return &App{poller: poller, username: me.Username, logger: logger}, nil
+}
+
+// Username is the bot's own username, as reported by getMe.
+func (a *App) Username() string { return a.username }
+
+// Offset is the identifier of the next update expected. It is meant for tests
+// and diagnostics.
+func (a *App) Offset() int64 { return a.poller.Offset() }
+
+// Run polls until ctx is cancelled and then shuts down cleanly.
+func (a *App) Run(ctx context.Context) error {
+ a.logger.Info("bot started", slog.String("username", a.username))
+ err := a.poller.Run(ctx)
+ a.logger.Info("bot stopped", slog.Int64("offset", a.poller.Offset()))
+ return err
+}
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
new file mode 100644
index 0000000..e770060
--- /dev/null
+++ b/internal/app/app_test.go
@@ -0,0 +1,510 @@
+package app
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "strings"
+ "testing"
+ "time"
+
+ "cep.is/telegrambot/internal/config"
+ "cep.is/telegrambot/internal/telegram"
+)
+
+func testConfig(tg *fakeTelegram, api *fakeCEPIS) config.Config {
+ cfg := config.Defaults()
+ cfg.Token = fakeToken
+ cfg.TelegramAPIBaseURL = tg.URL()
+ cfg.CepisBaseURL = api.URL()
+ cfg.PollTimeout = time.Second
+ cfg.RequestTimeout = 2 * time.Second
+ cfg.MaxAttempts = 2
+ cfg.BackoffBase = 5 * time.Millisecond
+ cfg.BackoffMax = 20 * time.Millisecond
+ cfg.ShutdownGrace = 3 * time.Second
+ return cfg
+}
+
+func quietLogger() *slog.Logger {
+ return slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
+}
+
+type running struct {
+ app *App
+ stop context.CancelFunc
+ done chan error
+}
+
+func (r *running) wait(t *testing.T) {
+ t.Helper()
+ r.stop()
+ select {
+ case err := <-r.done:
+ r.done <- err
+ case <-time.After(10 * time.Second):
+ t.Fatal("the bot did not shut down")
+ }
+}
+
+func start(t *testing.T, cfg config.Config) *running {
+ t.Helper()
+ ctx, cancel := context.WithCancel(context.Background())
+
+ application, err := New(ctx, cfg, quietLogger())
+ if err != nil {
+ cancel()
+ t.Fatalf("New: %v", err)
+ }
+
+ done := make(chan error, 1)
+ go func() { done <- application.Run(ctx) }()
+
+ r := &running{app: application, stop: cancel, done: done}
+ t.Cleanup(func() {
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ }
+ })
+ return r
+}
+
+func privateMessage(id int64, userID int64, text string) telegram.Update {
+ return telegram.Update{
+ UpdateID: id,
+ Message: &telegram.Message{
+ MessageID: id,
+ From: &telegram.User{ID: userID, FirstName: "Tester"},
+ Chat: telegram.Chat{ID: userID, Type: "private"},
+ Text: text,
+ },
+ }
+}
+
+func groupMessage(id int64, text string) telegram.Update {
+ return telegram.Update{
+ UpdateID: id,
+ Message: &telegram.Message{
+ MessageID: id,
+ From: &telegram.User{ID: 777, FirstName: "Tester"},
+ Chat: telegram.Chat{ID: -100200, Type: "supergroup", Title: "Grupo"},
+ Text: text,
+ },
+ }
+}
+
+func channelPost(id int64, text string) telegram.Update {
+ return telegram.Update{
+ UpdateID: id,
+ ChannelPost: &telegram.Message{
+ MessageID: id,
+ Chat: telegram.Chat{ID: -100999, Type: "channel", Title: "Canal"},
+ Text: text,
+ },
+ }
+}
+
+func inlineQuery(id int64, userID int64, query string) telegram.Update {
+ return telegram.Update{
+ UpdateID: id,
+ InlineQuery: &telegram.InlineQuery{
+ ID: "iq-" + query,
+ From: telegram.User{ID: userID, FirstName: "Tester"},
+ Query: query,
+ },
+ }
+}
+
+func waitForMessages(t *testing.T, tg *fakeTelegram, n int) []telegram.SendMessageRequest {
+ t.Helper()
+ waitFor(t, "messages", 10*time.Second, func() bool { return len(tg.sentMessages()) >= n })
+ return tg.sentMessages()
+}
+
+func TestGetMeIdentifiesTheBot(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ r := start(t, testConfig(tg, api))
+ if r.app.Username() != fakeUsername {
+ t.Fatalf("username = %q, want %q", r.app.Username(), fakeUsername)
+ }
+}
+
+func TestPrivateBareCEP(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(privateMessage(1, 555, "01001-000"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if !strings.Contains(sent[0].Text, "Praça da Sé") {
+ t.Fatalf("reply does not carry the address: %q", sent[0].Text)
+ }
+ if sent[0].ChatID != 555 {
+ t.Fatalf("chat id = %d, want 555", sent[0].ChatID)
+ }
+ if sent[0].ReplyParameters != nil {
+ t.Fatal("a private answer should not quote the message")
+ }
+}
+
+func TestGroupCommand(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(groupMessage(1, "/cep 01001000"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if !strings.Contains(sent[0].Text, "Praça da Sé") {
+ t.Fatalf("reply does not carry the address: %q", sent[0].Text)
+ }
+ if sent[0].ReplyParameters == nil || sent[0].ReplyParameters.MessageID != 1 {
+ t.Fatal("a group answer should quote the message that asked")
+ }
+}
+
+func TestGroupCommandAddressedToTheBot(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(groupMessage(1, "/cep@"+fakeUsername+" 01001000"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if !strings.Contains(sent[0].Text, "Praça da Sé") {
+ t.Fatalf("reply does not carry the address: %q", sent[0].Text)
+ }
+}
+
+func TestCommandAddressedToAnotherBotIsIgnored(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(
+ groupMessage(1, "/cep@outrobot 01001000"),
+ groupMessage(2, "/cep 01001000"),
+ )
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ time.Sleep(200 * time.Millisecond)
+ if got := len(tg.sentMessages()); got != 1 {
+ t.Fatalf("sent %d messages, want exactly 1", got)
+ }
+ if sent[0].ReplyParameters.MessageID != 2 {
+ t.Fatalf("answered message %d, want 2", sent[0].ReplyParameters.MessageID)
+ }
+}
+
+func TestChannelPost(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(channelPost(1, "/cep@"+fakeUsername+" 01001000"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if sent[0].ChatID != -100999 {
+ t.Fatalf("chat id = %d, want -100999", sent[0].ChatID)
+ }
+ if !strings.Contains(sent[0].Text, "Praça da Sé") {
+ t.Fatalf("reply does not carry the address: %q", sent[0].Text)
+ }
+}
+
+func TestGroupTextWithoutCommandIsIgnored(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(
+ groupMessage(1, "01001000"),
+ groupMessage(2, "/cep 01001000"),
+ )
+ start(t, testConfig(tg, api))
+
+ waitForMessages(t, tg, 1)
+ time.Sleep(200 * time.Millisecond)
+ if got := len(tg.sentMessages()); got != 1 {
+ t.Fatalf("sent %d messages, want exactly 1", got)
+ }
+}
+
+func TestInlineQuery(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(inlineQuery(1, 555, "01001000"))
+ start(t, testConfig(tg, api))
+
+ waitFor(t, "inline answer", 10*time.Second, func() bool { return len(tg.inlineAnswers()) >= 1 })
+ answer := tg.inlineAnswers()[0]
+ if len(answer.Results) != 1 {
+ t.Fatalf("got %d results, want 1", len(answer.Results))
+ }
+ result := answer.Results[0]
+ if result.Type != "article" {
+ t.Fatalf("result type = %q, want article", result.Type)
+ }
+ if !strings.Contains(result.Title, "01001-000") {
+ t.Fatalf("title = %q", result.Title)
+ }
+ if !strings.Contains(result.InputMessageContent.MessageText, "Praça da Sé") {
+ t.Fatalf("message content = %q", result.InputMessageContent.MessageText)
+ }
+}
+
+func TestInlineQueryWhileStillTypingCostsNothing(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(inlineQuery(1, 555, "0100"))
+ start(t, testConfig(tg, api))
+
+ waitFor(t, "inline answer", 10*time.Second, func() bool { return len(tg.inlineAnswers()) >= 1 })
+ if got := len(tg.inlineAnswers()[0].Results); got != 0 {
+ t.Fatalf("got %d results for a partial CEP, want 0", got)
+ }
+ if got := api.requestCount(); got != 0 {
+ t.Fatalf("the API was called %d times for a partial CEP, want 0", got)
+ }
+}
+
+func TestStartAndHelp(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(
+ privateMessage(1, 555, "/start"),
+ privateMessage(2, 555, "/help"),
+ )
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 2)
+ for _, message := range sent {
+ for _, needed := range []string{"https://cep.is", "/cep 01001000", "@" + fakeUsername} {
+ if !strings.Contains(message.Text, needed) {
+ t.Fatalf("help text is missing %q: %q", needed, message.Text)
+ }
+ }
+ }
+ if !strings.Contains(sent[0].Text, "Olá") {
+ t.Fatalf("/start should greet: %q", sent[0].Text)
+ }
+}
+
+func TestMalformedCEP(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(groupMessage(1, "/cep 123"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if !strings.Contains(sent[0].Text, "inválido") {
+ t.Fatalf("reply = %q, want an invalid-CEP message", sent[0].Text)
+ }
+ if got := api.requestCount(); got != 0 {
+ t.Fatalf("the API was called %d times for a malformed CEP, want 0", got)
+ }
+}
+
+func TestCEPNotFound(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(privateMessage(1, 555, "99999999"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if !strings.Contains(sent[0].Text, "não encontrado") {
+ t.Fatalf("reply = %q, want a not-found message", sent[0].Text)
+ }
+}
+
+func TestAPIServerError(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(privateMessage(1, 555, "50000000"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if !strings.Contains(sent[0].Text, "respondeu com erro") {
+ t.Fatalf("reply = %q, want a server-error message", sent[0].Text)
+ }
+}
+
+func TestAPINetworkFailure(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ cfg := testConfig(tg, api)
+ cfg.MaxAttempts = 1
+ api.server.Close()
+
+ tg.push(privateMessage(1, 555, "01001000"))
+ start(t, cfg)
+
+ sent := waitForMessages(t, tg, 1)
+ if !strings.Contains(sent[0].Text, "Não consegui falar com a cep.is") {
+ t.Fatalf("reply = %q, want a network-failure message", sent[0].Text)
+ }
+}
+
+func TestRateLimiterLimits(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+
+ var updates []telegram.Update
+ for i := 1; i <= 8; i++ {
+ updates = append(updates, privateMessage(int64(i), 555, "01001000"))
+ }
+ tg.push(updates...)
+ start(t, testConfig(tg, api))
+
+ waitFor(t, "the limiter to kick in", 10*time.Second, func() bool {
+ for _, message := range tg.sentMessages() {
+ if strings.Contains(message.Text, "muitas consultas") {
+ return true
+ }
+ }
+ return false
+ })
+ time.Sleep(300 * time.Millisecond)
+
+ sent := tg.sentMessages()
+ addresses, warnings := 0, 0
+ for _, message := range sent {
+ switch {
+ case strings.Contains(message.Text, "Praça da Sé"):
+ addresses++
+ case strings.Contains(message.Text, "muitas consultas"):
+ warnings++
+ }
+ }
+ if addresses != 5 {
+ t.Fatalf("answered %d lookups, want 5 (the burst)", addresses)
+ }
+ if warnings != 1 {
+ t.Fatalf("sent %d warnings, want exactly 1 per window", warnings)
+ }
+ if got := api.requestCount(); got != 5 {
+ t.Fatalf("the API saw %d requests, want 5", got)
+ }
+}
+
+func TestTelegram429RetryAfterIsHonoured(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.failNextSends(cannedFailure{
+ status: 429, code: 429, description: "Too Many Requests: retry after 1", retryAfter: 1,
+ })
+ tg.push(privateMessage(1, 555, "01001000"))
+
+ started := time.Now()
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ elapsed := time.Since(started)
+ if elapsed < time.Second {
+ t.Fatalf("retried after %v, want at least the 1s Telegram asked for", elapsed)
+ }
+ if !strings.Contains(sent[0].Text, "Praça da Sé") {
+ t.Fatalf("reply = %q", sent[0].Text)
+ }
+}
+
+func TestOffsetAdvancesAcrossPolls(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(privateMessage(10, 555, "01001000"))
+ r := start(t, testConfig(tg, api))
+
+ waitForMessages(t, tg, 1)
+ tg.push(privateMessage(11, 555, "99999999"))
+ waitForMessages(t, tg, 2)
+
+ waitFor(t, "the second offset to be confirmed", 5*time.Second, func() bool {
+ for _, offset := range tg.requestedOffsets() {
+ if offset == 12 {
+ return true
+ }
+ }
+ return false
+ })
+
+ offsets := tg.requestedOffsets()
+ if offsets[0] != 0 {
+ t.Fatalf("first poll asked for offset %d, want 0", offsets[0])
+ }
+ seen11 := false
+ for _, offset := range offsets {
+ if offset == 11 {
+ seen11 = true
+ }
+ if offset > 12 {
+ t.Fatalf("offset ran past the last update: %v", offsets)
+ }
+ }
+ if !seen11 {
+ t.Fatalf("offset never confirmed the first update: %v", offsets)
+ }
+ r.wait(t)
+ if r.app.Offset() != 12 {
+ t.Fatalf("final offset = %d, want 12", r.app.Offset())
+ }
+}
+
+func TestGracefulShutdownKeepsTheOffset(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(
+ privateMessage(1, 555, "01001000"),
+ privateMessage(2, 555, "99999999"),
+ )
+ r := start(t, testConfig(tg, api))
+
+ waitForMessages(t, tg, 2)
+ r.wait(t)
+
+ offsets := tg.requestedOffsets()
+ last := offsets[len(offsets)-1]
+ if last != 3 {
+ t.Fatalf("last offset sent was %d, want 3 so the handled updates stay confirmed", last)
+ }
+ if got := tg.pendingCount(); got != 0 {
+ t.Fatalf("%d updates are still unconfirmed and would be replayed", got)
+ }
+}
+
+func TestPrecisionChangesTheReply(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(
+ privateMessage(1, 555, "01001000"),
+ privateMessage(2, 556, "76200000"),
+ )
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 2)
+ street, municipality := sent[0].Text, sent[1].Text
+
+ if street == municipality {
+ t.Fatal("both precisions produced the same reply")
+ }
+ if !strings.Contains(street, "openstreetmap.org") {
+ t.Fatalf("a street-level CEP should carry a map link: %q", street)
+ }
+ if !strings.Contains(street, "128 m") {
+ t.Fatalf("a street-level CEP should state its radius: %q", street)
+ }
+
+ if strings.Contains(municipality, "openstreetmap.org") {
+ t.Fatalf("a municipality CEP must never carry a map link: %q", municipality)
+ }
+ if !strings.Contains(municipality, "CEP geral de município") {
+ t.Fatalf("a municipality CEP must say so: %q", municipality)
+ }
+ if !strings.Contains(municipality, "18,4 km") {
+ t.Fatalf("a municipality CEP must state its radius: %q", municipality)
+ }
+ if !strings.Contains(municipality, "centroide") {
+ t.Fatalf("a municipality CEP must name the point: %q", municipality)
+ }
+}
+
+func TestAddressWithoutCoordinate(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ tg.push(privateMessage(1, 555, "29902000"))
+ start(t, testConfig(tg, api))
+
+ sent := waitForMessages(t, tg, 1)
+ if strings.Contains(sent[0].Text, "openstreetmap.org") {
+ t.Fatalf("no coordinate should mean no map link: %q", sent[0].Text)
+ }
+ if !strings.Contains(sent[0].Text, "Sem coordenada") {
+ t.Fatalf("reply = %q", sent[0].Text)
+ }
+}
+
+func TestBadTokenFailsAtStartup(t *testing.T) {
+ tg, api := newFakeTelegram(t), newFakeCEPIS(t)
+ cfg := testConfig(tg, api)
+ cfg.Token = "424242:a-different-token"
+
+ if _, err := New(context.Background(), cfg, quietLogger()); err == nil {
+ t.Fatal("New accepted a token the API rejects")
+ }
+}
diff --git a/internal/app/fakes_test.go b/internal/app/fakes_test.go
new file mode 100644
index 0000000..011d4af
--- /dev/null
+++ b/internal/app/fakes_test.go
@@ -0,0 +1,285 @@
+package app
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "cep.is/telegrambot/internal/telegram"
+)
+
+const fakeToken = "424242:not-a-real-token-generated-for-tests"
+
+const fakeUsername = "cepisbot"
+
+type cannedFailure struct {
+ status int
+ code int
+ description string
+ retryAfter int
+}
+
+// fakeTelegram is a real HTTP server speaking the Bot API, including the
+// confirmation semantics of getUpdates: an update stays pending until a later
+// call arrives with a higher offset. Without that, an offset bug in the poller
+// would go unnoticed.
+type fakeTelegram struct {
+ server *httptest.Server
+ username string
+
+ mu sync.Mutex
+ pending []telegram.Update
+ offsets []int64
+ sent []telegram.SendMessageRequest
+ inline []telegram.AnswerInlineQueryRequest
+ sendFailures []cannedFailure
+ getMeCalls int
+}
+
+func newFakeTelegram(t *testing.T) *fakeTelegram {
+ t.Helper()
+ f := &fakeTelegram{username: fakeUsername}
+ f.server = httptest.NewServer(f)
+ t.Cleanup(f.server.Close)
+ return f
+}
+
+func (f *fakeTelegram) URL() string { return f.server.URL }
+
+func (f *fakeTelegram) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ prefix, method, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
+ if !ok || prefix != "bot"+fakeToken {
+ writeEnvelope(w, http.StatusUnauthorized, envelope{
+ OK: false, ErrorCode: 401, Description: "Unauthorized",
+ })
+ return
+ }
+
+ switch method {
+ case "getMe":
+ f.mu.Lock()
+ f.getMeCalls++
+ f.mu.Unlock()
+ writeResult(w, telegram.User{ID: 42, IsBot: true, FirstName: "cep.is", Username: f.username})
+ case "getUpdates":
+ f.getUpdates(w, r)
+ case "sendMessage":
+ f.sendMessage(w, r)
+ case "answerInlineQuery":
+ var req telegram.AnswerInlineQueryRequest
+ decode(r, &req)
+ f.mu.Lock()
+ f.inline = append(f.inline, req)
+ f.mu.Unlock()
+ writeResult(w, true)
+ default:
+ writeEnvelope(w, http.StatusNotFound, envelope{
+ OK: false, ErrorCode: 404, Description: "Not Found: method not found",
+ })
+ }
+}
+
+func (f *fakeTelegram) getUpdates(w http.ResponseWriter, r *http.Request) {
+ var req telegram.GetUpdatesRequest
+ decode(r, &req)
+
+ f.mu.Lock()
+ f.offsets = append(f.offsets, req.Offset)
+ if req.Offset > 0 {
+ kept := f.pending[:0]
+ for _, update := range f.pending {
+ if update.UpdateID >= req.Offset {
+ kept = append(kept, update)
+ }
+ }
+ f.pending = kept
+ }
+ f.mu.Unlock()
+
+ deadline := time.Now().Add(time.Duration(req.Timeout) * time.Second)
+ for {
+ f.mu.Lock()
+ out := append([]telegram.Update(nil), f.pending...)
+ f.mu.Unlock()
+
+ if len(out) > 0 || time.Now().After(deadline) {
+ if req.Limit > 0 && len(out) > req.Limit {
+ out = out[:req.Limit]
+ }
+ writeResult(w, out)
+ return
+ }
+ select {
+ case <-r.Context().Done():
+ writeResult(w, []telegram.Update{})
+ return
+ case <-time.After(5 * time.Millisecond):
+ }
+ }
+}
+
+func (f *fakeTelegram) sendMessage(w http.ResponseWriter, r *http.Request) {
+ var req telegram.SendMessageRequest
+ decode(r, &req)
+
+ f.mu.Lock()
+ if len(f.sendFailures) > 0 {
+ failure := f.sendFailures[0]
+ f.sendFailures = f.sendFailures[1:]
+ f.mu.Unlock()
+ body := envelope{OK: false, ErrorCode: failure.code, Description: failure.description}
+ if failure.retryAfter > 0 {
+ body.Parameters = &telegram.ResponseParameters{RetryAfter: failure.retryAfter}
+ }
+ writeEnvelope(w, failure.status, body)
+ return
+ }
+ f.sent = append(f.sent, req)
+ f.mu.Unlock()
+
+ writeResult(w, map[string]any{"message_id": 1})
+}
+
+func (f *fakeTelegram) push(updates ...telegram.Update) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.pending = append(f.pending, updates...)
+}
+
+func (f *fakeTelegram) failNextSends(failures ...cannedFailure) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.sendFailures = append(f.sendFailures, failures...)
+}
+
+func (f *fakeTelegram) sentMessages() []telegram.SendMessageRequest {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]telegram.SendMessageRequest(nil), f.sent...)
+}
+
+func (f *fakeTelegram) inlineAnswers() []telegram.AnswerInlineQueryRequest {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]telegram.AnswerInlineQueryRequest(nil), f.inline...)
+}
+
+func (f *fakeTelegram) requestedOffsets() []int64 {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]int64(nil), f.offsets...)
+}
+
+func (f *fakeTelegram) pendingCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return len(f.pending)
+}
+
+// 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.
+type fakeCEPIS struct {
+ server *httptest.Server
+
+ mu sync.Mutex
+ bodies map[string]string
+ statuses map[string]int
+ requests int
+}
+
+func newFakeCEPIS(t *testing.T) *fakeCEPIS {
+ t.Helper()
+ f := &fakeCEPIS{
+ bodies: map[string]string{
+ "01001000": `{"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",` +
+ `"unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP","estado":"São Paulo",` +
+ `"regiao":"Sudeste","ibge":"3550308","gia":"1004","ddd":"11","siafi":"7107",` +
+ `"geo":{"lat":-23.567202,"lon":-46.649659,"raio_m":128,"precisao":"logradouro"}}`,
+ "76200000": `{"cep":"76200000","logradouro":"","complemento":"","unidade":"",` +
+ `"bairro":"","localidade":"Iporá","uf":"GO","estado":"Goiás","regiao":"Centro-Oeste",` +
+ `"ibge":"5210000","gia":"","ddd":"64","siafi":"9439",` +
+ `"geo":{"lat":-16.441,"lon":-51.118,"raio_m":18400,"precisao":"localidade"}}`,
+ "29902000": `{"cep":"29902000","logradouro":"Rua Sem Coordenada","complemento":"",` +
+ `"unidade":"","bairro":"Centro","localidade":"Linhares","uf":"ES","estado":"Espírito Santo",` +
+ `"regiao":"Sudeste","ibge":"3203205","gia":"","ddd":"27","siafi":"5665"}`,
+ },
+ statuses: map[string]int{
+ "50000000": http.StatusInternalServerError,
+ },
+ }
+ f.server = httptest.NewServer(http.HandlerFunc(f.handle))
+ t.Cleanup(f.server.Close)
+ return f
+}
+
+func (f *fakeCEPIS) URL() string { return f.server.URL }
+
+func (f *fakeCEPIS) handle(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ f.requests++
+ cep := strings.TrimPrefix(r.URL.Path, "/")
+ status, forced := f.statuses[cep]
+ body, known := f.bodies[cep]
+ f.mu.Unlock()
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ switch {
+ case forced:
+ w.WriteHeader(status)
+ w.Write([]byte(`{"erro":"interno","mensagem":"falha interna"}`))
+ case known:
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(body))
+ case len(cep) != 8:
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"erro":"cep_invalido","mensagem":"CEP deve ter 8 dígitos"}`))
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte(`{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}`))
+ }
+}
+
+func (f *fakeCEPIS) requestCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.requests
+}
+
+type envelope struct {
+ OK bool `json:"ok"`
+ Result any `json:"result,omitempty"`
+ ErrorCode int `json:"error_code,omitempty"`
+ Description string `json:"description,omitempty"`
+ Parameters *telegram.ResponseParameters `json:"parameters,omitempty"`
+}
+
+func writeResult(w http.ResponseWriter, result any) {
+ writeEnvelope(w, http.StatusOK, envelope{OK: true, Result: result})
+}
+
+func writeEnvelope(w http.ResponseWriter, status int, body envelope) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(body)
+}
+
+func decode(r *http.Request, out any) {
+ json.NewDecoder(r.Body).Decode(out)
+}
+
+func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %s", what)
+}