dyzur-bot/service_test.go

296 lines
9.8 KiB
Go

package dyzurbot
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
// setCredFile points GOOGLE_CREDENTIALS_FILE at an existing temp file so
// LoadConfig's startup validation passes without depending on the developer's
// local (gitignored) .env. The contents are irrelevant: LoadConfig only stats
// the path; the file is parsed later, by the Sheets client.
func setCredFile(t *testing.T) {
t.Helper()
path := filepath.Join(t.TempDir(), "sa.json")
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatalf("write temp cred file: %v", err)
}
t.Setenv("GOOGLE_CREDENTIALS_FILE", path)
}
func TestLoadConfigDefaults(t *testing.T) {
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
t.Setenv("SHEET_RANGE", "")
t.Setenv("ROTA_CACHE_PATH", "")
t.Setenv("SYNC_INTERVAL", "")
setCredFile(t)
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.SpreadsheetID != "sheet-123" {
t.Errorf("SpreadsheetID: got %q", cfg.SpreadsheetID)
}
// An unset SHEET_RANGE stays empty in config; the fetcher resolves the
// current-year tab per request so the year isn't frozen at startup.
if cfg.SheetRange != "" {
t.Errorf("SheetRange default: got %q, want empty (resolved at fetch time)", cfg.SheetRange)
}
if cfg.CachePath != "rota.json" {
t.Errorf("CachePath default: got %q", cfg.CachePath)
}
if cfg.SyncInterval != 30*time.Minute {
t.Errorf("SyncInterval default: got %v", cfg.SyncInterval)
}
}
func TestLoadConfigRequiresSpreadsheetID(t *testing.T) {
t.Setenv("GOOGLE_SHEETS_ID", "")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected error when GOOGLE_SHEETS_ID is missing")
}
}
func TestLoadConfigRequiresCredentialsFile(t *testing.T) {
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
t.Setenv("GOOGLE_CREDENTIALS_FILE", "")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected error when GOOGLE_CREDENTIALS_FILE is missing")
}
}
func TestLoadConfigRejectsMissingCredentialsFile(t *testing.T) {
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
t.Setenv("GOOGLE_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "does-not-exist.json"))
if _, err := LoadConfig(); err == nil {
t.Fatal("expected error when GOOGLE_CREDENTIALS_FILE points to a nonexistent file")
}
}
func TestLoadConfigRejectsBadInterval(t *testing.T) {
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
setCredFile(t)
t.Setenv("SYNC_INTERVAL", "not-a-duration")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected error for unparseable SYNC_INTERVAL")
}
}
func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) {
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
setCredFile(t)
t.Setenv("SYNC_INTERVAL", "0s")
if _, err := LoadConfig(); err == nil {
t.Fatal("expected error for non-positive SYNC_INTERVAL")
}
}
func TestLoadReminderConfigDisabled(t *testing.T) {
t.Setenv("GROUP_CHAT_ID", "")
r, err := loadReminderConfig()
if err != nil {
t.Fatalf("loadReminderConfig: %v", err)
}
if r != nil {
t.Errorf("expected nil (disabled), got %+v", r)
}
}
func TestLoadReminderConfigParses(t *testing.T) {
// Negative chat ID guards against ParseInt being swapped for ParseUint:
// Telegram group IDs are negative.
t.Setenv("GROUP_CHAT_ID", "-5572351978")
t.Setenv("REMINDER_WEEKDAY", "Friday")
t.Setenv("REMINDER_HOUR", "18")
r, err := loadReminderConfig()
if err != nil {
t.Fatalf("loadReminderConfig: %v", err)
}
if r == nil {
t.Fatal("expected a config, got nil")
}
if r.ChatID != -5572351978 {
t.Errorf("ChatID = %d, want -5572351978", r.ChatID)
}
if r.Weekday != time.Friday || r.Hour != 18 {
t.Errorf("got %v %d, want Friday 18", r.Weekday, r.Hour)
}
if r.Loc == nil || r.Loc.String() != "Europe/Warsaw" {
t.Errorf("Loc = %v, want Europe/Warsaw", r.Loc)
}
}
func TestLoadReminderConfigDefaults(t *testing.T) {
t.Setenv("GROUP_CHAT_ID", "42")
t.Setenv("REMINDER_WEEKDAY", "")
t.Setenv("REMINDER_HOUR", "")
r, err := loadReminderConfig()
if err != nil {
t.Fatalf("loadReminderConfig: %v", err)
}
if r.Weekday != time.Monday || r.Hour != 9 {
t.Errorf("defaults: got %v %d, want Monday 9", r.Weekday, r.Hour)
}
}
func TestLoadReminderConfigRejects(t *testing.T) {
tests := []struct {
name string
key string
val string
}{
{"bad chat id", "GROUP_CHAT_ID", "not-a-number"},
{"hour too high", "REMINDER_HOUR", "24"},
{"hour negative", "REMINDER_HOUR", "-1"},
{"bad weekday", "REMINDER_WEEKDAY", "Funday"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("GROUP_CHAT_ID", "42") // enabled by default; case may override
t.Setenv("REMINDER_WEEKDAY", "Monday")
t.Setenv("REMINDER_HOUR", "9")
t.Setenv(tc.key, tc.val)
if _, err := loadReminderConfig(); err == nil {
t.Fatalf("expected error for %s=%q", tc.key, tc.val)
}
})
}
}
func TestSyncSuccessReplacesCache(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "rota.json"))
fixed := time.Date(2026, 6, 17, 20, 0, 0, 0, time.UTC)
sy := NewSyncer(store, func(context.Context) (Rota, error) { return sampleRota(), nil })
sy.now = func() time.Time { return fixed }
if err := sy.Sync(context.Background()); err != nil {
t.Fatalf("Sync: %v", err)
}
rota, ts := store.Snapshot()
if len(rota.People) != 1 || !ts.Equal(fixed) {
t.Errorf("cache not updated: people=%d ts=%v", len(rota.People), ts)
}
}
// 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)
// 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")
}
}
}
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. 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.
if got, want := resolveRange("Dyżury!A1:C30", now), "Dyżury!A1:C30"; got != want {
t.Errorf("resolveRange(explicit): got %q, want %q", got, want)
}
}
func TestStaleAge(t *testing.T) {
now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
threshold := 6 * time.Hour
// A never-synced cache (zero time) is not stale: there is no good data behind.
if _, stale := staleAge(time.Time{}, now, threshold); stale {
t.Error("zero last_synced should not be stale")
}
// Within the threshold: fresh.
if _, stale := staleAge(now.Add(-time.Hour), now, threshold); stale {
t.Error("1h-old cache should not be stale under a 6h threshold")
}
// Past the threshold: stale, with the age reported.
if age, stale := staleAge(now.Add(-7*time.Hour), now, threshold); !stale || age != 7*time.Hour {
t.Errorf("7h-old cache: stale=%v age=%v, want stale age=7h", stale, age)
}
}
// TestSyncEmptyRotaPreservesCache guards the New-Year regression: a fetch that
// succeeds but returns no weeks (e.g. a freshly created, empty year tab) must
// not clobber the last-good cache.
func TestSyncEmptyRotaPreservesCache(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "rota.json"))
good := time.Date(2026, 6, 17, 20, 0, 0, 0, time.UTC)
if err := store.Replace(sampleRota(), good); err != nil {
t.Fatalf("seed Replace: %v", err)
}
sy := NewSyncer(store, func(context.Context) (Rota, error) { return Rota{}, nil })
if err := sy.Sync(context.Background()); !errors.Is(err, errEmptyRota) {
t.Fatalf("Sync error: got %v, want errEmptyRota", err)
}
rota, ts := store.Snapshot()
if len(rota.People) != 1 || !ts.Equal(good) {
t.Errorf("cache should be untouched: people=%d ts=%v", len(rota.People), ts)
}
}
func TestSyncFailurePreservesCache(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "rota.json"))
good := time.Date(2026, 6, 17, 20, 0, 0, 0, time.UTC)
if err := store.Replace(sampleRota(), good); err != nil {
t.Fatalf("seed Replace: %v", err)
}
wantErr := errors.New("sheet unreachable")
sy := NewSyncer(store, func(context.Context) (Rota, error) { return Rota{}, wantErr })
if err := sy.Sync(context.Background()); !errors.Is(err, wantErr) {
t.Fatalf("Sync error: got %v, want %v", err, wantErr)
}
rota, ts := store.Snapshot()
if len(rota.People) != 1 || !ts.Equal(good) {
t.Errorf("cache should be untouched: people=%d ts=%v", len(rota.People), ts)
}
}