fix/duty-week-rollover #1

Merged
kamash merged 2 commits from fix/duty-week-rollover into main 2026-08-19 15:20:49 +00:00
9 changed files with 201 additions and 192 deletions

27
.env.example Normal file
View File

@ -0,0 +1,27 @@
# Copy to .env and fill in the required values. Never commit .env or the
# service-account key file.
# --- Required ---
# Telegram bot token from @BotFather.
BOT_TOKEN=
# Path to the Google service-account JSON key. The sheet must be shared with
# the service account's client_email (Editor for assignment writes).
GOOGLE_CREDENTIALS_FILE=
# Spreadsheet ID from the Google Sheets URL.
GOOGLE_SHEETS_ID=
# --- Optional (defaults shown) ---
# Range to read; leave unset to follow the current year's tab automatically
# (e.g. 2026!A1:G200), including the New Year rollover.
#SHEET_RANGE=
# Path of the local JSON rota cache.
#ROTA_CACHE_PATH=rota.json
# Background sync frequency (Go duration).
#SYNC_INTERVAL=30m
# --- Reminders (disabled unless GROUP_CHAT_ID is set) ---
# Telegram chat ID to post weekly reminders to.
#GROUP_CHAT_ID=
# Day and hour (0-23, Europe/Warsaw time) the weekly reminder fires.
#REMINDER_WEEKDAY=Monday
#REMINDER_HOUR=9

View File

@ -8,16 +8,24 @@ on:
jobs:
build:
runs-on: docker
container: golang:1.26-alpine
container: golang:1.26.4-alpine
steps:
- name: Install tools
run: apk add --no-cache git docker-cli
# build-base provides the C toolchain the race detector needs (-race
# implies CGO).
run: apk add --no-cache git docker-cli build-base
- name: Checkout
run: git clone ${{ github.server_url }}/${{ github.repository }} . && git checkout ${{ github.sha }}
- name: Run tests
run: go test ./...
run: go test -race ./...
- name: Run linter
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run
- name: Check for known vulnerabilities
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
- name: Log in to container registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.kambr.pl -u "${{ secrets.REGISTRY_USER }}" --password-stdin

View File

@ -8,8 +8,10 @@ Backend read the schedule from a Google sheet, store it in local JSON file.
Requires `GOOGLE_CREDENTIALS_FILE` (path to a Google service-account key,
shared as an editor on the sheet), `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the
environment or a `.env` file (see `service.go`/`sheet.go` for optional
`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then:
environment or a `.env` file — copy `.env.example` to `.env` as a starting
point; it lists every variable (including the optional `SHEET_RANGE`,
`ROTA_CACHE_PATH`, `SYNC_INTERVAL`, and the reminder settings) with defaults.
Then:
```sh
go run ./cmd/dyzur-bot
@ -20,7 +22,7 @@ The service loads the cached rota, syncs from Google Sheets in the background on
interrupted (Ctrl-C / SIGTERM).
When `SHEET_RANGE` is unset, the bot reads the current year's tab (e.g.
`2026!A1:G100`), resolved on every sync — so it follows the New Year rollover
`2026!A1:G200`), resolved on every sync — so it follows the New Year rollover
automatically without a restart. A sync that fails, or that returns no duty
weeks (e.g. a next-year tab that exists but hasn't been filled in yet), keeps
the last-good cache instead of clearing it; if no sync succeeds for 6h the bot
@ -49,6 +51,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.

View File

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

View File

@ -218,14 +218,27 @@ func SheetFetcher(spreadsheetID, readRange string) FetchFunc {
return func(ctx context.Context) (Rota, error) {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
rows, err := ReadSheet(ctx, spreadsheetID, resolveRange(readRange, time.Now()))
rng := resolveRange(readRange, time.Now())
rows, err := ReadSheet(ctx, spreadsheetID, rng)
if err != nil {
return Rota{}, err
}
return ParseRota(rows), nil
return stampTabs(ParseRota(rows), rng), nil
}
}
// stampTabs records on each week the tab its rows were fetched from, so an
// assignment written later (possibly after the clock-resolved tab has rolled to
// the next year while the cache still holds this fetch) targets the tab the
// week's Row is valid on.
func stampTabs(rota Rota, resolvedRange string) Rota {
tab, _, _ := strings.Cut(resolvedRange, "!")
for i := range rota.Weeks {
rota.Weeks[i].Tab = tab
}
return rota
}
// SheetAssigner returns an AssignFunc that writes a week's two names into its
// B:C cells on the same tab the rota is read from. sheetRange mirrors the
// fetcher's range so the tab (year or explicit) matches.
@ -233,28 +246,38 @@ func SheetAssigner(spreadsheetID, sheetRange string) AssignFunc {
return func(ctx context.Context, week DutyWeek, p1, p2 string) error {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
rng := assignRange(sheetRange, time.Now(), week.Row)
rng := assignRangeFor(week, sheetRange, time.Now())
return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}})
}
}
// assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are
// columns B and C). It reuses the tab from resolveRange so the year rollover and
// any explicit range are honored identically to reads.
func assignRange(sheetRange string, now time.Time, row int) string {
tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!")
return fmt.Sprintf("%s!B%d:C%d", tab, row, row)
// assignRangeFor builds the A1 range for a week's assignment cells (Osoba 1/2
// are columns B and C). An explicit sheetRange keeps its tab verbatim, matching
// reads. Otherwise the week's fetch-time tab is preferred over clock resolution:
// around New Year the cache can hold the old tab's rows (the empty-rota guard
// keeps them while the next year's tab is unfilled) after resolveRange has moved
// to the new tab, and writing an old-tab Row onto the new tab would corrupt it.
// A week without a recorded tab (cache from an older version) falls back to the
// clock, preserving prior behavior.
func assignRangeFor(week DutyWeek, sheetRange string, now time.Time) string {
tab := week.Tab
if sheetRange != "" || tab == "" {
tab, _, _ = strings.Cut(resolveRange(sheetRange, now), "!")
}
return fmt.Sprintf("%s!B%d:C%d", tab, week.Row, week.Row)
}
// resolveRange returns the explicit range when set, otherwise the default that
// targets the current year's tab (sheets are named per year, e.g. "2026").
// Resolving against now (not a startup-captured value) is what lets the range
// advance to the next year's tab automatically.
// advance to the next year's tab automatically. The 200-row cap bounds the
// fetch; rows past it are silently dropped, so it must stay comfortably above
// one year of weekly rows (~53) plus roster entries.
func resolveRange(explicit string, now time.Time) string {
if explicit != "" {
return explicit
}
return fmt.Sprintf("%d!A1:G100", now.Year())
return fmt.Sprintf("%d!A1:G200", now.Year())
}
// Service ties the Store and Syncer together for the bot to consume.

View File

@ -181,16 +181,43 @@ func TestSyncSuccessReplacesCache(t *testing.T) {
}
}
func TestAssignRange(t *testing.T) {
now := time.Date(2026, 6, 19, 9, 0, 0, 0, time.UTC)
// Regression test for the year-boundary tab/row divergence: a week cached from
// the old year's tab (e.g. kept by the empty-rota guard across New Year) must be
// assigned on the tab it was fetched from, not the tab the wall clock resolves
// to — its Row is only meaningful on the fetch tab.
func TestAssignRangeForUsesFetchTab(t *testing.T) {
jan2027 := time.Date(2027, 1, 4, 9, 0, 0, 0, time.UTC)
// Empty range -> current-year tab; the assignment targets cols B:C of the row.
if got, want := assignRange("", now, 5), "2026!B5:C5"; got != want {
t.Errorf("assignRange(\"\"): got %q, want %q", got, want)
// Week fetched from the 2026 tab, assigned after the clock rolled to 2027.
week := DutyWeek{Row: 54, Tab: "2026"}
if got, want := assignRangeFor(week, "", jan2027), "2026!B54:C54"; got != want {
t.Errorf("assignRangeFor(fetch tab): got %q, want %q", got, want)
}
// A week without a recorded tab (cache written by an older version) falls
// back to clock-resolved tab, matching the previous behavior.
legacy := DutyWeek{Row: 54}
if got, want := assignRangeFor(legacy, "", jan2027), "2027!B54:C54"; got != want {
t.Errorf("assignRangeFor(legacy fallback): got %q, want %q", got, want)
}
// An explicit range keeps its tab verbatim over the week's recorded tab.
explicit := DutyWeek{Row: 7, Tab: "2026"}
if got, want := assignRangeFor(explicit, "Dyżury!A1:C30", jan2027), "Dyżury!B7:C7"; got != want {
t.Errorf("assignRangeFor(explicit): got %q, want %q", got, want)
}
}
func TestStampTabs(t *testing.T) {
rota := ParseRota(sampleRows())
stamped := stampTabs(rota, "2026!A1:G200")
if len(stamped.Weeks) == 0 {
t.Fatal("sample rota has no weeks")
}
for _, w := range stamped.Weeks {
if w.Tab != "2026" {
t.Errorf("week %s: Tab = %q, want %q", w.WeekStart.Format("2006-01-02"), w.Tab, "2026")
}
// Explicit range -> its tab is reused verbatim.
if got, want := assignRange("Dyżury!A1:C30", now, 7), "Dyżury!B7:C7"; got != want {
t.Errorf("assignRange(explicit): got %q, want %q", got, want)
}
}
@ -198,8 +225,10 @@ func TestResolveRange(t *testing.T) {
now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
// Empty range resolves to the current year's tab, evaluated against now —
// this is what advances the range after a New Year rollover.
if got, want := resolveRange("", now), "2027!A1:G100"; got != want {
// this is what advances the range after a New Year rollover. The row cap
// (200) is a deliberate constant: rows past it are silently dropped by the
// fetch, so keep it comfortably above one year of weekly rows.
if got, want := resolveRange("", now), "2027!A1:G200"; got != want {
t.Errorf("resolveRange(\"\"): got %q, want %q", got, want)
}
// An explicit range is honored verbatim regardless of the year.

View File

@ -32,6 +32,11 @@ type DutyWeek struct {
// cells when writing an assignment back. Valid only when the read range
// starts at A1 (as resolveRange always produces); 0 means unknown.
Row int
// Tab is the sheet tab Row was fetched from (e.g. "2026"). Row is only
// meaningful on this tab: around New Year the cache may still hold the old
// year's rows while the clock-resolved tab has moved on, so an assignment
// must target Tab, not the current year. "" means unknown (older cache).
Tab string
}
// Person is a single entry from the people list (columns EG).

View File

@ -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,

View File

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