fix/duty-week-rollover #1
|
|
@ -49,6 +49,9 @@ across restarts.
|
|||
- `/sync` — refresh the rota from Google Sheets now (throttled to once per
|
||||
minute across the chat to avoid spamming the Sheets API).
|
||||
|
||||
Any other command is ignored — the bot stays silent instead of replying
|
||||
"unknown command".
|
||||
|
||||
### Reminders
|
||||
|
||||
If `GROUP_CHAT_ID` is set, the bot posts a weekly duty reminder to that chat.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -59,7 +60,6 @@ func TestFire(t *testing.T) {
|
|||
return DutyWeek{WeekStart: day(t, start), Person1: "X", Person2: "Y", Row: row}
|
||||
}
|
||||
thisWeekFilled := DutyWeek{WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5}
|
||||
wantThisReminder := "Przypomnienie: w tygodniu od 2026-06-15 dyżur mają Jan i Anna. Z góry dziękujemy!"
|
||||
|
||||
t.Run("this week empty -> reminder only, never assigns", func(t *testing.T) {
|
||||
rota := Rota{
|
||||
|
|
@ -76,9 +76,8 @@ func TestFire(t *testing.T) {
|
|||
if len(h.assigns) != 0 {
|
||||
t.Errorf("assigns = %d, want 0 (this week is never auto-assigned)", len(h.assigns))
|
||||
}
|
||||
want := "Przypomnienie: w tygodniu od 2026-06-15 dyżur jest nieobsadzony."
|
||||
if len(h.sends) != 1 || h.sends[0] != want {
|
||||
t.Errorf("sends = %q, want [%q]", h.sends, want)
|
||||
if len(h.sends) != 1 {
|
||||
t.Errorf("sends = %q, want exactly the this-week reminder", h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -101,9 +100,9 @@ func TestFire(t *testing.T) {
|
|||
if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 6 {
|
||||
t.Errorf("assign = %+v, want Ala/Bartek on row 6", a)
|
||||
}
|
||||
wantAnnounce := "Nikt nie zgłosił się na dyżur w tygodniu od 2026-06-22, więc został przydzielony losowo: Ala i Bartek. Z góry dziękujemy za sprzątanie!"
|
||||
if len(h.sends) != 2 || h.sends[0] != wantThisReminder || h.sends[1] != wantAnnounce {
|
||||
t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantThisReminder, wantAnnounce)
|
||||
if len(h.sends) != 2 ||
|
||||
!strings.Contains(h.sends[1], "Ala") || !strings.Contains(h.sends[1], "Bartek") {
|
||||
t.Errorf("sends = %q, want reminder + announcement naming Ala and Bartek", h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -122,9 +121,8 @@ func TestFire(t *testing.T) {
|
|||
if len(h.assigns) != 0 {
|
||||
t.Errorf("assigns = %d, want 0 (warning only, no assign at +2)", len(h.assigns))
|
||||
}
|
||||
wantWarning := "Dyżur w tygodniu od 2026-06-29 jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo."
|
||||
if len(h.sends) != 2 || h.sends[0] != wantThisReminder || h.sends[1] != wantWarning {
|
||||
t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantThisReminder, wantWarning)
|
||||
if len(h.sends) != 2 {
|
||||
t.Errorf("sends = %q, want reminder + warning", h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -143,8 +141,8 @@ func TestFire(t *testing.T) {
|
|||
if len(h.assigns) != 0 {
|
||||
t.Errorf("assigns = %d, want 0", len(h.assigns))
|
||||
}
|
||||
if len(h.sends) != 1 || h.sends[0] != wantThisReminder {
|
||||
t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder)
|
||||
if len(h.sends) != 1 {
|
||||
t.Errorf("sends = %q, want only the this-week reminder", h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -163,8 +161,8 @@ func TestFire(t *testing.T) {
|
|||
if len(h.assigns) != 0 {
|
||||
t.Errorf("assigns = %d, want 0 (unwritable row)", len(h.assigns))
|
||||
}
|
||||
if len(h.sends) != 1 || h.sends[0] != wantThisReminder {
|
||||
t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder)
|
||||
if len(h.sends) != 1 {
|
||||
t.Errorf("sends = %q, want only the this-week reminder", h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -184,8 +182,8 @@ func TestFire(t *testing.T) {
|
|||
if len(h.assigns) != 1 {
|
||||
t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns))
|
||||
}
|
||||
if len(h.sends) != 1 || h.sends[0] != wantThisReminder {
|
||||
t.Errorf("sends = %q, want [%q] (no announce on write failure)", h.sends, wantThisReminder)
|
||||
if len(h.sends) != 1 {
|
||||
t.Errorf("sends = %q, want only the this-week reminder (no announce on write failure)", h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -204,8 +202,8 @@ func TestFire(t *testing.T) {
|
|||
if len(h.assigns) != 0 {
|
||||
t.Errorf("assigns = %d, want 0 (partial week is not empty)", len(h.assigns))
|
||||
}
|
||||
if len(h.sends) != 1 || h.sends[0] != wantThisReminder {
|
||||
t.Errorf("sends = %q, want [%q]", h.sends, wantThisReminder)
|
||||
if len(h.sends) != 1 {
|
||||
t.Errorf("sends = %q, want only the this-week reminder", h.sends)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -354,54 +352,23 @@ func TestNextFireAcrossDST(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestReminderText checks only the match/skip behavior — a target inside a
|
||||
// rota week yields a reminder, one outside skips. The wording itself is not
|
||||
// asserted, so copy tweaks don't break the test.
|
||||
func TestReminderText(t *testing.T) {
|
||||
// Week of 2026-06-29; target lands inside it.
|
||||
target := day(t, "2026-06-30")
|
||||
rota := func(p1, p2 string) Rota {
|
||||
return Rota{Weeks: []DutyWeek{week(t, "2026-06-29", p1, p2)}}
|
||||
}
|
||||
rota := Rota{Weeks: []DutyWeek{week(t, "2026-06-29", "Ala", "Bartek")}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rota Rota
|
||||
target time.Time
|
||||
wantOK bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"both people",
|
||||
rota("Ala", "Bartek"), target, true,
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur mają Ala i Bartek. Z góry dziękujemy!",
|
||||
},
|
||||
{
|
||||
"one person",
|
||||
rota("Ala", ""), target, true,
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur ma Ala. Z góry dziękujemy!",
|
||||
},
|
||||
{
|
||||
"nobody assigned",
|
||||
rota("", ""), target, true,
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur jest nieobsadzony.",
|
||||
},
|
||||
{
|
||||
"no matching week -> skip",
|
||||
rota("Ala", "Bartek"), day(t, "2026-09-01"), false,
|
||||
"",
|
||||
},
|
||||
if _, ok := reminderText(rota, day(t, "2026-06-30")); !ok {
|
||||
t.Error("target inside a rota week should yield a reminder")
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := reminderText(tc.rota, tc.target)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Errorf("reminderText = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
if _, ok := reminderText(rota, day(t, "2026-09-01")); ok {
|
||||
t.Error("target outside all rota weeks should skip")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReminderTextMentions checks the handle substitution — names resolve to
|
||||
// @handles when known (normalizing a missing leading @) and stay plain names
|
||||
// otherwise. Only the mention tokens are asserted, not the full wording.
|
||||
func TestReminderTextMentions(t *testing.T) {
|
||||
target := day(t, "2026-06-30")
|
||||
people := []Person{
|
||||
|
|
@ -416,28 +383,12 @@ func TestReminderTextMentions(t *testing.T) {
|
|||
tests := []struct {
|
||||
name string
|
||||
rota Rota
|
||||
want string
|
||||
wantTokens []string // each must appear in the reminder text
|
||||
}{
|
||||
{
|
||||
"both have handles",
|
||||
rota("Ala", "Bartek"),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur mają @ala i @bart. Z góry dziękujemy!",
|
||||
},
|
||||
{
|
||||
"one handle, one missing -> mixed",
|
||||
rota("Ala", "Cela"),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur mają @ala i Cela. Z góry dziękujemy!",
|
||||
},
|
||||
{
|
||||
"name not in people list -> plain name",
|
||||
rota("Ala", "Zenon"),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur mają @ala i Zenon. Z góry dziękujemy!",
|
||||
},
|
||||
{
|
||||
"single person with handle",
|
||||
rota("Bartek", ""),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur ma @bart. Z góry dziękujemy!",
|
||||
},
|
||||
{"both have handles", rota("Ala", "Bartek"), []string{"@ala", "@bart"}},
|
||||
{"one handle, one missing -> mixed", rota("Ala", "Cela"), []string{"@ala", "Cela"}},
|
||||
{"name not in people list -> plain name", rota("Ala", "Zenon"), []string{"@ala", "Zenon"}},
|
||||
{"single person with handle", rota("Bartek", ""), []string{"@bart"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
|
@ -445,8 +396,10 @@ func TestReminderTextMentions(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("ok = false, want true")
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("reminderText = %q, want %q", got, tc.want)
|
||||
for _, token := range tc.wantTokens {
|
||||
if !strings.Contains(got, token) {
|
||||
t.Errorf("reminderText = %q, want it to contain %q", got, token)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
14
telegram.go
14
telegram.go
|
|
@ -156,16 +156,16 @@ func (h *botHandlers) sync(ctx context.Context, b *bot.Bot, update *models.Updat
|
|||
"Zsynchronizowano: %d tygodni, %d osób.", len(rota.Weeks), len(rota.People)))
|
||||
}
|
||||
|
||||
// unknown is the fallback for any update the command matchers don't claim. It
|
||||
// only answers messages that actually carry a bot_command entity (a mistyped or
|
||||
// unsupported /command); plain chatter, service messages (joins/pins), media,
|
||||
// and replies to the bot fall through here too but get no response, so the bot
|
||||
// stays quiet in groups instead of "Nieznana komenda"-spamming every message.
|
||||
func (h *botHandlers) unknown(ctx context.Context, b *bot.Bot, update *models.Update) {
|
||||
// unknown is the fallback for any update the command matchers don't claim.
|
||||
// It never replies: the bot answers only its predefined commands and ignores
|
||||
// everything else — mistyped /commands, other bots' commands, plain chatter,
|
||||
// service messages (joins/pins), and media. Unrecognized commands are logged
|
||||
// at debug level so they stay visible without adding group noise.
|
||||
func (h *botHandlers) unknown(_ context.Context, _ *bot.Bot, update *models.Update) {
|
||||
if update.Message == nil || !hasCommandEntity(update.Message.Entities) {
|
||||
return
|
||||
}
|
||||
h.reply(ctx, b, update, "Nieznana komenda. Użyj /kto_sprzata lub /sync.")
|
||||
slog.Debug("ignoring unknown command", "text", update.Message.Text)
|
||||
}
|
||||
|
||||
// hasCommandEntity reports whether the message carries any bot_command entity,
|
||||
|
|
|
|||
105
telegram_test.go
105
telegram_test.go
|
|
@ -214,7 +214,8 @@ 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.
|
||||
// replies with this week's duty. Only the data (the names) is asserted, not the
|
||||
// exact wording, so copy tweaks don't break the test.
|
||||
func TestKtoSprzataReply(t *testing.T) {
|
||||
rota := Rota{Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")}}
|
||||
svc := testService(t, rota, nil)
|
||||
|
|
@ -224,14 +225,13 @@ func TestKtoSprzataReply(t *testing.T) {
|
|||
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)
|
||||
if len(got) != 1 || !strings.Contains(got[0], "Ala") || !strings.Contains(got[0], "Bartek") {
|
||||
t.Fatalf("reply = %v, want one message naming Ala and Bartek", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncReplySuccess checks /sync triggers a fetch and reports the resulting
|
||||
// week/person counts.
|
||||
// TestSyncReplySuccess checks /sync triggers a fetch, persists the result, and
|
||||
// replies once.
|
||||
func TestSyncReplySuccess(t *testing.T) {
|
||||
fetched := Rota{
|
||||
Weeks: []DutyWeek{week(t, "2026-06-15", "Ala", "Bartek")},
|
||||
|
|
@ -244,15 +244,17 @@ func TestSyncReplySuccess(t *testing.T) {
|
|||
|
||||
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)
|
||||
if got := sent(); len(got) != 1 {
|
||||
t.Fatalf("replies = %v, want exactly one", got)
|
||||
}
|
||||
rota, _ := svc.Store.Snapshot()
|
||||
if len(rota.Weeks) != 1 {
|
||||
t.Errorf("store weeks = %d, want 1 (sync should persist the fetch)", len(rota.Weeks))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncReplyThrottled checks a /sync inside the cooldown window is rejected
|
||||
// with the remaining-wait message and does not trigger a fetch.
|
||||
// TestSyncReplyThrottled checks a /sync inside the cooldown window does not
|
||||
// trigger a fetch but still answers (so the user isn't left hanging).
|
||||
func TestSyncReplyThrottled(t *testing.T) {
|
||||
now := fixedNow()()
|
||||
fetch := func(context.Context) (Rota, error) {
|
||||
|
|
@ -266,16 +268,14 @@ func TestSyncReplyThrottled(t *testing.T) {
|
|||
|
||||
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)
|
||||
if got := sent(); len(got) != 1 {
|
||||
t.Fatalf("replies = %v, want exactly one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TestSyncReplyFailure checks a failed fetch still yields exactly one reply,
|
||||
// and that internal error details are not leaked into it (they are logged
|
||||
// instead — 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
|
||||
|
|
@ -287,15 +287,18 @@ func TestSyncReplyFailure(t *testing.T) {
|
|||
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)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("replies = %v, want exactly one", got)
|
||||
}
|
||||
if strings.Contains(got[0], context.DeadlineExceeded.Error()) {
|
||||
t.Errorf("reply %q leaks the internal error", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownReply checks the fallback answers an unrecognized /command (a
|
||||
// message carrying a bot_command entity that no matcher claimed).
|
||||
func TestUnknownReply(t *testing.T) {
|
||||
// TestUnknownCommandIgnored checks the fallback stays silent on an
|
||||
// unrecognized /command (a bot_command entity no matcher claimed) — the bot
|
||||
// answers only its own predefined commands and ignores everything else.
|
||||
func TestUnknownCommandIgnored(t *testing.T) {
|
||||
svc := testService(t, Rota{}, nil)
|
||||
b, sent := newTestBot(t)
|
||||
h := &botHandlers{svc: svc, now: fixedNow()}
|
||||
|
|
@ -307,16 +310,14 @@ func TestUnknownReply(t *testing.T) {
|
|||
}}
|
||||
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)
|
||||
if got := sent(); len(got) != 0 {
|
||||
t.Fatalf("unknown command should not reply, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// gets no reply — the bot stays silent on group messages, service notices, and
|
||||
// replies to its own messages.
|
||||
func TestUnknownPlainTextIgnored(t *testing.T) {
|
||||
svc := testService(t, Rota{}, nil)
|
||||
b, sent := newTestBot(t)
|
||||
|
|
@ -381,45 +382,3 @@ func TestPublishCommands(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue