405 lines
12 KiB
Go
405 lines
12 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 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
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|