From 41980e4f6f3ee90d8fb00b5e4a95d9fb08adebdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20=27Kama=C5=9B=27=20Bruchal?= Date: Fri, 19 Jun 2026 19:04:04 +0200 Subject: [PATCH] test: cover Telegram command handlers via httptest bot Add tests for the previously-untested handlers (ktoSprzata, sync, unknown, matchCommand, BotSendFunc, PublishCommands) using a real *bot.Bot pointed at an httptest server that captures sendMessage replies. Covers the /sync success, throttled, and failure paths plus the non-message fallback. --- telegram_test.go | 223 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/telegram_test.go b/telegram_test.go index bd3a42a..e0d0b15 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -1,12 +1,87 @@ package dyzurbot import ( + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" "testing" "time" + "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" ) +// newTestBot builds a real *bot.Bot pointed at an httptest server that records +// the text of every sendMessage call, so handler replies can be asserted +// without touching the network. WithSkipGetMe avoids the token-validating +// getMe call the real constructor makes. The returned func yields the captured +// reply texts in order. Per-path responses matter: sendMessage must decode into +// a models.Message and setMyCommands into a bool, so a single generic body would +// make one of them fail to unmarshal (and the handler would log a send error). +func newTestBot(t *testing.T) (*bot.Bot, func() []string) { + t.Helper() + var mu sync.Mutex + var sent []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/sendMessage") { + _ = r.ParseMultipartForm(1 << 20) + mu.Lock() + sent = append(sent, r.FormValue("text")) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true,"result":true}`) + })) + t.Cleanup(srv.Close) + + b, err := bot.New("xxx", bot.WithServerURL(srv.URL), bot.WithSkipGetMe()) + if err != nil { + t.Fatalf("bot.New: %v", err) + } + return b, func() []string { + mu.Lock() + defer mu.Unlock() + out := make([]string, len(sent)) + copy(out, sent) + return out + } +} + +// testService builds a Service backed by a temp-file Store (optionally seeded +// with rota) and a Syncer using the given fetch. fetch may be nil for handlers +// that don't sync. +func testService(t *testing.T, rota Rota, fetch FetchFunc) *Service { + t.Helper() + store := NewStore(filepath.Join(t.TempDir(), "rota.json")) + if len(rota.Weeks) > 0 || len(rota.People) > 0 { + if err := store.Replace(rota, time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC)); err != nil { + t.Fatalf("seed store: %v", err) + } + } + return &Service{Store: store, Syncer: NewSyncer(store, fetch)} +} + +// updateMsg builds a minimal text-message Update addressed to chatID. +func updateMsg(chatID int64, text string) *models.Update { + return &models.Update{Message: &models.Message{ + Text: text, + Chat: models.Chat{ID: chatID}, + }} +} + +// fixedNow returns a clock pinned to a Wednesday inside the 2026-06-15 week. +func fixedNow() func() time.Time { + now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC) + return func() time.Time { return now } +} + // cmdEntity builds the bot_command entity Telegram attaches to a leading // command, spanning the whole token (slash, name, and any @addressee). func cmdEntity(text string) []models.MessageEntity { @@ -138,6 +213,154 @@ func TestCurrentDutyLocalMidnight(t *testing.T) { } } +// TestKtoSprzataReply checks the /kto_sprzata handler reads the cached rota and +// replies with this week's duty rendered by formatDuty. +func TestKtoSprzataReply(t *testing.T) { + rota := Rota{Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")}} + svc := testService(t, rota, nil) + b, sent := newTestBot(t) + h := &botHandlers{svc: svc, now: fixedNow()} + + h.ktoSprzata(context.Background(), b, updateMsg(123, "/kto_sprzata")) + + got := sent() + want := "Dyżur w tym tygodniu (od 2026-06-15): Ala i Bartek" + if len(got) != 1 || got[0] != want { + t.Fatalf("reply = %v, want [%q]", got, want) + } +} + +// TestSyncReplySuccess checks /sync triggers a fetch and reports the resulting +// week/person counts. +func TestSyncReplySuccess(t *testing.T) { + fetched := Rota{ + Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")}, + People: []Person{{Name: "Ala"}, {Name: "Bartek"}, {Name: "Celina"}}, + } + fetch := func(context.Context) (Rota, error) { return fetched, nil } + svc := testService(t, Rota{}, fetch) + b, sent := newTestBot(t) + h := &botHandlers{svc: svc, now: fixedNow()} + + h.sync(context.Background(), b, updateMsg(123, "/sync")) + + got := sent() + want := "Zsynchronizowano: 1 tygodni, 3 osób." + if len(got) != 1 || got[0] != want { + t.Fatalf("reply = %v, want [%q]", got, want) + } +} + +// TestSyncReplyThrottled checks a /sync inside the cooldown window is rejected +// with the remaining-wait message and does not trigger a fetch. +func TestSyncReplyThrottled(t *testing.T) { + now := fixedNow()() + fetch := func(context.Context) (Rota, error) { + t.Fatal("fetch must not run while throttled") + return Rota{}, nil + } + svc := testService(t, Rota{}, fetch) + b, sent := newTestBot(t) + h := &botHandlers{svc: svc, now: func() time.Time { return now }} + h.lastSync = now.Add(-30 * time.Second) // 30s into a 60s cooldown + + h.sync(context.Background(), b, updateMsg(123, "/sync")) + + got := sent() + want := "Synchronizowano niedawno. Spróbuj ponownie za 31s." + if len(got) != 1 || got[0] != want { + t.Fatalf("reply = %v, want [%q]", got, want) + } +} + +// TestSyncReplyFailure checks a failed fetch yields the generic failure message +// (internal error details are logged, not shown). The slog.Error this emits is +// expected, not a harness fault. +func TestSyncReplyFailure(t *testing.T) { + fetch := func(context.Context) (Rota, error) { + return Rota{}, context.DeadlineExceeded + } + svc := testService(t, Rota{}, fetch) + b, sent := newTestBot(t) + h := &botHandlers{svc: svc, now: fixedNow()} + + h.sync(context.Background(), b, updateMsg(123, "/sync")) + + got := sent() + want := "Synchronizacja nie powiodła się. Spróbuj ponownie później." + if len(got) != 1 || got[0] != want { + t.Fatalf("reply = %v, want [%q]", got, want) + } +} + +// TestUnknownReply checks the fallback handler answers unrecognized text. +func TestUnknownReply(t *testing.T) { + svc := testService(t, Rota{}, nil) + b, sent := newTestBot(t) + h := &botHandlers{svc: svc, now: fixedNow()} + + h.unknown(context.Background(), b, updateMsg(123, "siema")) + + got := sent() + want := "Nieznana komenda. Użyj /kto_sprzata lub /sync." + if len(got) != 1 || got[0] != want { + t.Fatalf("reply = %v, want [%q]", got, want) + } +} + +// TestUnknownNonMessageIgnored checks a non-message update (e.g. an edited +// message or callback) produces no reply. +func TestUnknownNonMessageIgnored(t *testing.T) { + svc := testService(t, Rota{}, nil) + b, sent := newTestBot(t) + h := &botHandlers{svc: svc, now: fixedNow()} + + h.unknown(context.Background(), b, &models.Update{}) + + if got := sent(); len(got) != 0 { + t.Fatalf("non-message update should not reply, got %v", got) + } +} + +// TestMatchCommand checks the MatchFunc wrapper fires for the matching command +// and tolerates a nil Message (non-message updates). +func TestMatchCommand(t *testing.T) { + h := &botHandlers{} + match := h.matchCommand(cmdSync) + + hit := &models.Update{Message: &models.Message{Text: "/sync", Entities: cmdEntity("/sync")}} + if !match(hit) { + t.Error("matchCommand(cmdSync) should fire on /sync") + } + if match(&models.Update{}) { + t.Error("matchCommand must not fire on a nil-message update") + } +} + +// TestBotSendFunc checks the reminder-facing SendFunc posts its text to the +// fixed chat. +func TestBotSendFunc(t *testing.T) { + b, sent := newTestBot(t) + send := BotSendFunc(b, 999) + + if err := send(context.Background(), "przypomnienie"); err != nil { + t.Fatalf("send: %v", err) + } + got := sent() + if len(got) != 1 || got[0] != "przypomnienie" { + t.Fatalf("sent = %v, want [%q]", got, "przypomnienie") + } +} + +// TestPublishCommands checks the command list is registered without error +// against a server that accepts setMyCommands. +func TestPublishCommands(t *testing.T) { + b, _ := newTestBot(t) + if err := PublishCommands(context.Background(), b); err != nil { + t.Fatalf("PublishCommands: %v", err) + } +} + func TestFormatDuty(t *testing.T) { tests := []struct { name string