dyzur-bot/telegram_test.go

426 lines
13 KiB
Go

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