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") } }