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 { return []models.MessageEntity{{ Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: len(text), }} } func TestMatchCommandEntity(t *testing.T) { tests := []struct { name string text string cmd string want bool }{ {"bare command (private chat)", "/sync", cmdSync, true}, {"command with @botusername (group)", "/sync@hs_dyzur_bot", cmdSync, true}, {"kto_sprzata with @botusername", "/kto_sprzata@hs_dyzur_bot", cmdKtoSprzata, true}, {"underscore name survives @suffix", "/kto_sprzata@hs_dyzur_bot", cmdSync, false}, {"wrong command", "/sync", cmdKtoSprzata, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := matchCommandEntity(tc.text, cmdEntity(tc.text), tc.cmd); got != tc.want { t.Errorf("matchCommandEntity(%q, %q) = %v, want %v", tc.text, tc.cmd, got, tc.want) } }) } } // A plain message carrying no bot_command entity must not match any command. func TestMatchCommandEntityNoEntity(t *testing.T) { if matchCommandEntity("just chatting about /sync", nil, cmdSync) { t.Error("text without a bot_command entity should not match") } } // week is a small helper to build a DutyWeek from a YYYY-MM-DD start. func week(t *testing.T, start, p1, p2 string) DutyWeek { t.Helper() ws, err := time.Parse("2006-01-02", start) if err != nil { t.Fatalf("bad date %q: %v", start, err) } return DutyWeek{WeekStart: ws, Person1: p1, Person2: p2} } func day(t *testing.T, d string) time.Time { t.Helper() tm, err := time.Parse("2006-01-02", d) if err != nil { t.Fatalf("bad date %q: %v", d, err) } return tm } func TestAllowSync(t *testing.T) { base := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC) h := &botHandlers{} // First attempt is always allowed and starts the cooldown. if ok, _ := h.allowSync(base); !ok { t.Fatal("first /sync should be allowed") } // A second attempt inside the window is denied, reporting the remaining wait. if ok, wait := h.allowSync(base.Add(20 * time.Second)); ok || wait != syncCooldown-20*time.Second { t.Errorf("within cooldown: ok=%v wait=%v, want denied with wait=%v", ok, wait, syncCooldown-20*time.Second) } // Once the cooldown elapses, /sync is allowed again. if ok, _ := h.allowSync(base.Add(syncCooldown)); !ok { t.Error("attempt after cooldown should be allowed") } } func TestCurrentDuty(t *testing.T) { rota := Rota{Weeks: []DutyWeek{ week(t, "2026-06-15", "Ala", "Bartek"), week(t, "2026-06-22", "Celina", "Darek"), }} tests := []struct { name string today time.Time wantOK bool wantStart string // WeekStart of the matched week, when wantOK }{ {"inside first week", day(t, "2026-06-17"), true, "2026-06-15"}, {"first day of first week", day(t, "2026-06-15"), true, "2026-06-15"}, {"last day of first week", day(t, "2026-06-21"), true, "2026-06-15"}, {"first day of second week", day(t, "2026-06-22"), true, "2026-06-22"}, {"before all weeks", day(t, "2026-06-14"), false, ""}, {"after all weeks", day(t, "2026-06-29"), false, ""}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, ok := currentDuty(rota, tc.today) if ok != tc.wantOK { t.Fatalf("ok = %v, want %v", ok, tc.wantOK) } if ok && got.WeekStart.Format("2006-01-02") != tc.wantStart { t.Errorf("matched week %s, want %s", got.WeekStart.Format("2006-01-02"), tc.wantStart) } }) } } func TestCurrentDutyEmptyRota(t *testing.T) { if _, ok := currentDuty(Rota{}, day(t, "2026-06-17")); ok { t.Error("empty rota should not match any week") } } // A local time just after midnight in a +02:00 zone is still the previous day // in UTC. currentDuty must match on the local calendar date, so 2026-06-15 // (Poland) lands in the week starting 2026-06-15, not the prior week. func TestCurrentDutyLocalMidnight(t *testing.T) { rota := Rota{Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")}} today := time.Date(2026, 6, 15, 0, 30, 0, 0, time.FixedZone("CEST", 2*3600)) got, ok := currentDuty(rota, today) if !ok { t.Fatal("expected a match for local 2026-06-15") } if got.Person1 != "Ala" { t.Errorf("matched wrong week: %+v", got) } } // 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 answers an unrecognized /command (a // message carrying a bot_command entity that no matcher claimed). func TestUnknownReply(t *testing.T) { svc := testService(t, Rota{}, nil) b, sent := newTestBot(t) h := &botHandlers{svc: svc, now: fixedNow()} cmd := &models.Update{Message: &models.Message{ Text: "/foo", Chat: models.Chat{ID: 123}, Entities: cmdEntity("/foo"), }} h.unknown(context.Background(), b, cmd) 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) } } // TestUnknownPlainTextIgnored checks ordinary chatter (no bot_command entity) // gets no reply, so the bot doesn't spam "Nieznana komenda" on every group // message, service notice, or reply to its own messages. func TestUnknownPlainTextIgnored(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")) if got := sent(); len(got) != 0 { t.Fatalf("plain text should not reply, got %v", got) } } // 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 week DutyWeek ok bool want string }{ { "both people", week(t, "2026-06-15", "Ala", "Bartek"), true, "Dyżur w tym tygodniu (od 2026-06-15): Ala i Bartek", }, { "only person1", week(t, "2026-06-15", "Ala", ""), true, "Dyżur w tym tygodniu (od 2026-06-15): Ala", }, { "only person2", week(t, "2026-06-15", "", "Bartek"), true, "Dyżur w tym tygodniu (od 2026-06-15): Bartek", }, { "nobody assigned", week(t, "2026-06-15", "", ""), true, "Dyżur w tym tygodniu (od 2026-06-15): brak przypisania", }, { "not found", DutyWeek{}, false, "Nie znalazłem dyżuru na ten tydzień.", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := formatDuty(tc.week, tc.ok); got != tc.want { t.Errorf("formatDuty = %q, want %q", got, tc.want) } }) } }