diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 15:18:21 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 15:18:21 -0300 |
| commit | 3ca1d2c4370e8e79aba3940d5c8fe115960b75a8 (patch) | |
| tree | aa964675c588b80e4f81182d34130ba2d7d984e1 /internal/app | |
| download | telegram-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'internal/app')
| -rw-r--r-- | internal/app/app.go | 96 | ||||
| -rw-r--r-- | internal/app/app_test.go | 510 | ||||
| -rw-r--r-- | internal/app/fakes_test.go | 285 |
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) +} |