feat: auto-assign empty duty weeks via service-account sheet writes
Migrate Sheets auth from an API key to a service-account credential
(GOOGLE_CREDENTIALS_FILE) with the read+write spreadsheets scope, add a
WriteSheet primitive, and use it to actively manage empty duty weeks in
the weekly reminder.
Each fire now handles a fixed this-week/next-week model (replacing the
DUTY_AHEAD_DAYS announce):
- this week empty -> pick the two people with the fewest duties (random
tie-break), write their names into cols B/C, and announce
- this week filled -> announce who is on duty
- next week empty -> ask people to volunteer before it is auto-assigned
Col G ("Liczba dyżurów") is a live formula, so names-only writes keep the
fewest-duties selection fair across weeks. Selection, row tracking, range
building, and every fire() branch are unit-tested; live tests for sheet
read/write are env-gated.
This commit is contained in:
parent
825331dce5
commit
6573144b14
|
|
@ -1,6 +1,7 @@
|
||||||
# Secrets and local state — never bake into the build context/image.
|
# Secrets and local state — never bake into the build context/image.
|
||||||
.env
|
.env
|
||||||
rota.json
|
rota.json
|
||||||
|
hackerspace-492302-a63993a1af9d.json
|
||||||
|
|
||||||
# VCS and tooling.
|
# VCS and tooling.
|
||||||
.git
|
.git
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
.env
|
.env
|
||||||
rota.json
|
rota.json
|
||||||
|
hackerspace-492302-a63993a1af9d.json
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -6,7 +6,8 @@ Backend read the schedule from a Google sheet, store it in local JSON file.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
Requires `GOOGLE_API_KEY`, `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the
|
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
|
environment or a `.env` file (see `service.go`/`sheet.go` for optional
|
||||||
`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then:
|
`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then:
|
||||||
|
|
||||||
|
|
@ -52,7 +53,14 @@ across restarts.
|
||||||
|
|
||||||
If `GROUP_CHAT_ID` is set, the bot posts a weekly duty reminder to that chat.
|
If `GROUP_CHAT_ID` is set, the bot posts a weekly duty reminder to that chat.
|
||||||
Schedule (all optional, with defaults): `REMINDER_WEEKDAY` (default `Monday`),
|
Schedule (all optional, with defaults): `REMINDER_WEEKDAY` (default `Monday`),
|
||||||
`REMINDER_HOUR` (default `9`, 0–23), `DUTY_AHEAD_DAYS` (default `14`). The hour
|
`REMINDER_HOUR` (default `9`, 0–23), interpreted in Europe/Warsaw time.
|
||||||
is interpreted in Europe/Warsaw time. The reminder announces who is on duty
|
|
||||||
`DUTY_AHEAD_DAYS` from the fire time — e.g. every Monday 09:00, who cleans two
|
On each fire the bot handles two weeks relative to the fire time:
|
||||||
weeks out. Leave `GROUP_CHAT_ID` unset to disable reminders.
|
|
||||||
|
- **This week** — announces who is on duty. If both slots are empty, it picks
|
||||||
|
the two people with the fewest duties (random tie-break), writes their names
|
||||||
|
into the sheet, and announces the assignment.
|
||||||
|
- **Next week** — if both slots are still empty, it asks people to volunteer
|
||||||
|
before it gets assigned automatically the following week.
|
||||||
|
|
||||||
|
Leave `GROUP_CHAT_ID` unset to disable reminders.
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,11 @@ func run() error {
|
||||||
|
|
||||||
if r := cfg.Reminder; r != nil {
|
if r := cfg.Reminder; r != nil {
|
||||||
send := dyzurbot.BotSendFunc(b, r.ChatID)
|
send := dyzurbot.BotSendFunc(b, r.ChatID)
|
||||||
go dyzurbot.NewReminder(svc.Store, send, *r).Run(ctx)
|
assign := dyzurbot.SheetAssigner(cfg.SpreadsheetID, cfg.SheetRange)
|
||||||
|
go dyzurbot.NewReminder(svc.Store, send, assign, *r).Run(ctx)
|
||||||
slog.Info("reminders on",
|
slog.Info("reminders on",
|
||||||
"weekday", r.Weekday, "hour", r.Hour, "tz", r.Loc.String(),
|
"weekday", r.Weekday, "hour", r.Hour, "tz", r.Loc.String(),
|
||||||
"days_ahead", r.AheadDays, "chat", r.ChatID)
|
"chat", r.ChatID)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("reminders disabled (no GROUP_CHAT_ID)")
|
slog.Info("reminders disabled (no GROUP_CHAT_ID)")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package dyzurbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"google.golang.org/api/sheets/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These are manual smoke tests that hit the real Google Sheet using the
|
||||||
|
// credential in .env (GOOGLE_CREDENTIALS_FILE, GOOGLE_SHEETS_ID). They are
|
||||||
|
// skipped unless their gate env var is set, so the normal `go test ./...` run
|
||||||
|
// stays offline and side-effect-free.
|
||||||
|
|
||||||
|
// TestLiveAuthRead proves the service-account credential loads and the sheet is
|
||||||
|
// reachable. Read-only. Gate: DYZUR_LIVE_AUTH=1.
|
||||||
|
func TestLiveAuthRead(t *testing.T) {
|
||||||
|
if os.Getenv("DYZUR_LIVE_AUTH") != "1" {
|
||||||
|
t.Skip("set DYZUR_LIVE_AUTH=1 to run")
|
||||||
|
}
|
||||||
|
_ = godotenv.Load()
|
||||||
|
id := os.Getenv("GOOGLE_SHEETS_ID")
|
||||||
|
rows, err := ReadSheet(context.Background(), id, resolveRange("", time.Now()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadSheet: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("OK: fetched %d rows", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLiveWriteRoundTrip proves the spreadsheets (read+write) scope actually
|
||||||
|
// grants writes: it creates a throwaway tab, writes a marker via WriteSheet,
|
||||||
|
// reads it back, and deletes the tab — so no real rota data is touched. Gate:
|
||||||
|
// DYZUR_LIVE_WRITE=1.
|
||||||
|
func TestLiveWriteRoundTrip(t *testing.T) {
|
||||||
|
if os.Getenv("DYZUR_LIVE_WRITE") != "1" {
|
||||||
|
t.Skip("set DYZUR_LIVE_WRITE=1 to run")
|
||||||
|
}
|
||||||
|
_ = godotenv.Load()
|
||||||
|
ctx := context.Background()
|
||||||
|
id := os.Getenv("GOOGLE_SHEETS_ID")
|
||||||
|
|
||||||
|
srv, err := newSheetsService(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newSheetsService: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an isolated throwaway tab; the timestamp keeps the title unique.
|
||||||
|
title := "scratch-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
addResp, err := srv.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
|
||||||
|
Requests: []*sheets.Request{{
|
||||||
|
AddSheet: &sheets.AddSheetRequest{
|
||||||
|
Properties: &sheets.SheetProperties{Title: title},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}).Context(ctx).Do()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create scratch tab: %v", err)
|
||||||
|
}
|
||||||
|
sheetID := addResp.Replies[0].AddSheet.Properties.SheetId
|
||||||
|
|
||||||
|
// Always remove the throwaway tab, even if the assertions below fail.
|
||||||
|
defer func() {
|
||||||
|
_, derr := srv.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
|
||||||
|
Requests: []*sheets.Request{{
|
||||||
|
DeleteSheet: &sheets.DeleteSheetRequest{SheetId: sheetID},
|
||||||
|
}},
|
||||||
|
}).Context(ctx).Do()
|
||||||
|
if derr != nil {
|
||||||
|
t.Errorf("cleanup: delete scratch tab %q: %v", title, derr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
const want = "dyzur-write-probe"
|
||||||
|
writeRange := title + "!A1"
|
||||||
|
if err := WriteSheet(ctx, id, writeRange, [][]interface{}{{want}}); err != nil {
|
||||||
|
t.Fatalf("WriteSheet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := ReadSheet(ctx, id, writeRange)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadSheet back: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 || len(rows[0]) == 0 || rows[0][0] != want {
|
||||||
|
t.Fatalf("round-trip mismatch: got %v, want %q", rows, want)
|
||||||
|
}
|
||||||
|
t.Logf("OK: wrote and read back %q from throwaway tab %q", want, title)
|
||||||
|
}
|
||||||
144
scheduler.go
144
scheduler.go
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -13,7 +15,6 @@ type ReminderConfig struct {
|
||||||
ChatID int64 // Telegram chat to post to
|
ChatID int64 // Telegram chat to post to
|
||||||
Weekday time.Weekday // day of week to fire
|
Weekday time.Weekday // day of week to fire
|
||||||
Hour int // hour of day (0–23) to fire
|
Hour int // hour of day (0–23) to fire
|
||||||
AheadDays int // announce the duty this many days from the fire time
|
|
||||||
Loc *time.Location // timezone the schedule is expressed in
|
Loc *time.Location // timezone the schedule is expressed in
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,23 +22,39 @@ type ReminderConfig struct {
|
||||||
// can be tested without a live bot (see BotSendFunc for the real wiring).
|
// can be tested without a live bot (see BotSendFunc for the real wiring).
|
||||||
type SendFunc func(ctx context.Context, text string) error
|
type SendFunc func(ctx context.Context, text string) error
|
||||||
|
|
||||||
// Reminder posts a duty reminder to a chat on a weekly schedule.
|
// AssignFunc persists a duty assignment: it writes p1/p2 into week's cells. It
|
||||||
|
// abstracts the sheet write so Reminder can be tested without Google (see
|
||||||
|
// SheetAssigner for the real wiring).
|
||||||
|
type AssignFunc func(ctx context.Context, week DutyWeek, p1, p2 string) error
|
||||||
|
|
||||||
|
// Reminder posts a duty reminder to a chat on a weekly schedule, and fills empty
|
||||||
|
// duty weeks by assigning people when nobody has volunteered.
|
||||||
type Reminder struct {
|
type Reminder struct {
|
||||||
store *Store
|
store *Store
|
||||||
send SendFunc
|
send SendFunc
|
||||||
|
assign AssignFunc
|
||||||
cfg ReminderConfig
|
cfg ReminderConfig
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
rng *rand.Rand
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewReminder wires a reminder to the rota cache and a send function.
|
// NewReminder wires a reminder to the rota cache, a send function, and an
|
||||||
func NewReminder(store *Store, send SendFunc, cfg ReminderConfig) *Reminder {
|
// assigner (used to persist auto-assigned duty weeks).
|
||||||
return &Reminder{store: store, send: send, cfg: cfg, now: time.Now}
|
func NewReminder(store *Store, send SendFunc, assign AssignFunc, cfg ReminderConfig) *Reminder {
|
||||||
|
return &Reminder{
|
||||||
|
store: store,
|
||||||
|
send: send,
|
||||||
|
assign: assign,
|
||||||
|
cfg: cfg,
|
||||||
|
now: time.Now,
|
||||||
|
// #nosec G404 -- duty selection is fairness, not security; a weak RNG is fine.
|
||||||
|
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run fires the reminder on schedule until ctx is canceled. Each iteration
|
// Run fires the reminder on schedule until ctx is canceled. Each iteration
|
||||||
// recomputes the next fire time, sleeps until then, and on fire looks up the
|
// recomputes the next fire time, sleeps until then, and on fire handles this and
|
||||||
// duty AheadDays out and posts it (skipping weeks with no data). Send failures
|
// next week (see fire). Send failures are logged and the loop continues.
|
||||||
// are logged and the loop continues.
|
|
||||||
func (r *Reminder) Run(ctx context.Context) {
|
func (r *Reminder) Run(ctx context.Context) {
|
||||||
for {
|
for {
|
||||||
now := r.now()
|
now := r.now()
|
||||||
|
|
@ -53,18 +70,66 @@ func (r *Reminder) Run(ctx context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fire performs one reminder: snapshot the cache, format, and send.
|
// fire performs one weekly run: it finalizes this week (announcing who is on
|
||||||
|
// duty, or auto-assigning when nobody volunteered) and warns when next week is
|
||||||
|
// still unassigned. Dates are computed in the schedule's zone so the calendar
|
||||||
|
// day matches the wall-clock fire day (consistent with nextFire); reading them
|
||||||
|
// in server-local time could land on the prior day on a UTC host.
|
||||||
func (r *Reminder) fire(ctx context.Context) {
|
func (r *Reminder) fire(ctx context.Context) {
|
||||||
rota, _ := r.store.Snapshot()
|
rota, _ := r.store.Snapshot()
|
||||||
// Compute the target in the schedule's zone so the calendar date matches
|
now := r.now().In(r.cfg.Loc)
|
||||||
// the wall-clock fire day (consistent with nextFire); reading it in
|
handles := handlesByName(rota.People)
|
||||||
// server-local time could land on the prior day on a UTC host.
|
|
||||||
target := r.now().In(r.cfg.Loc).AddDate(0, 0, r.cfg.AheadDays)
|
r.handleThisWeek(ctx, rota, now, handles)
|
||||||
text, ok := reminderText(rota, target)
|
r.handleNextWeek(ctx, rota, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleThisWeek announces the current duty, or auto-assigns it when both slots
|
||||||
|
// are empty.
|
||||||
|
func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time, handles map[string]string) {
|
||||||
|
week, ok := currentDuty(rota, now)
|
||||||
if !ok {
|
if !ok {
|
||||||
slog.Info("reminder: no duty week, skipping", "target", target.Format("2006-01-02"))
|
slog.Info("reminder: no week row for this week, skipping", "date", now.Format("2006-01-02"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if week.Person1 != "" || week.Person2 != "" {
|
||||||
|
if text, ok := reminderText(rota, now); ok {
|
||||||
|
r.sendLogged(ctx, text)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both slots empty: assign the two people with the fewest duties.
|
||||||
|
p1, p2, ok := chooseTwo(rota.People, r.rng)
|
||||||
|
if !ok {
|
||||||
|
slog.Info("reminder: this week empty but fewer than 2 people, skipping assignment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if week.Row == 0 {
|
||||||
|
slog.Warn("reminder: this week empty but sheet row unknown, cannot assign",
|
||||||
|
"week", week.WeekStart.Format("2006-01-02"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.assign(ctx, week, p1, p2); err != nil {
|
||||||
|
slog.Error("reminder: assign write failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.sendLogged(ctx, assignedText(week, p1, p2, handles))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNextWeek warns when next week is still completely unassigned.
|
||||||
|
func (r *Reminder) handleNextWeek(ctx context.Context, rota Rota, now time.Time) {
|
||||||
|
week, ok := currentDuty(rota, now.AddDate(0, 0, 7))
|
||||||
|
if !ok || week.Person1 != "" || week.Person2 != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.sendLogged(ctx, nextWeekWarningText(week))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendLogged sends text, logging (not propagating) any failure so one bad send
|
||||||
|
// does not abort the rest of the run.
|
||||||
|
func (r *Reminder) sendLogged(ctx context.Context, text string) {
|
||||||
if err := r.send(ctx, text); err != nil {
|
if err := r.send(ctx, text); err != nil {
|
||||||
slog.Error("reminder send failed", "err", err)
|
slog.Error("reminder send failed", "err", err)
|
||||||
}
|
}
|
||||||
|
|
@ -114,8 +179,28 @@ func reminderText(rota Rota, target time.Time) (string, bool) {
|
||||||
body = "dyżur sprzątają " + strings.Join(people, " i ")
|
body = "dyżur sprzątają " + strings.Join(people, " i ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tail := "."
|
||||||
|
if len(people) > 0 {
|
||||||
|
tail = ". Proszę pamiętać o sprzątaniu."
|
||||||
|
}
|
||||||
return "Przypomnienie: w tygodniu od " +
|
return "Przypomnienie: w tygodniu od " +
|
||||||
week.WeekStart.Format("2006-01-02") + " " + body + ".", true
|
week.WeekStart.Format("2006-01-02") + " " + body + tail, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignedText announces an auto-assigned duty week (both people just chosen).
|
||||||
|
func assignedText(week DutyWeek, p1, p2 string, handles map[string]string) string {
|
||||||
|
return "Nikt nie zgłosił się na dyżur w tym tygodniu (od " +
|
||||||
|
week.WeekStart.Format("2006-01-02") + "), więc został przydzielony losowo: " +
|
||||||
|
mention(p1, handles) + " i " + mention(p2, handles) +
|
||||||
|
". Z góry dziękujemy za sprzątanie!"
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextWeekWarningText asks people to volunteer for an as-yet-unassigned next
|
||||||
|
// week before it is filled automatically.
|
||||||
|
func nextWeekWarningText(week DutyWeek) string {
|
||||||
|
return "Dyżur na przyszły tydzień (od " +
|
||||||
|
week.WeekStart.Format("2006-01-02") +
|
||||||
|
") jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo."
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlesByName indexes the roster's Telegram handles by person name, so a
|
// handlesByName indexes the roster's Telegram handles by person name, so a
|
||||||
|
|
@ -140,6 +225,33 @@ func mention(name string, handles map[string]string) string {
|
||||||
return "@" + strings.TrimPrefix(h, "@")
|
return "@" + strings.TrimPrefix(h, "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chooseTwo picks the two people with the fewest recorded duties, returning the
|
||||||
|
// lower-count one first. Equal counts are broken randomly (via rng) so no one is
|
||||||
|
// systematically favored among ties. Entries with a blank name are ignored;
|
||||||
|
// ok is false when fewer than two eligible people remain.
|
||||||
|
func chooseTwo(people []Person, rng *rand.Rand) (p1, p2 string, ok bool) {
|
||||||
|
eligible := make([]Person, 0, len(people))
|
||||||
|
for _, p := range people {
|
||||||
|
if strings.TrimSpace(p.Name) != "" {
|
||||||
|
eligible = append(eligible, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(eligible) < 2 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle first, then stable-sort by count: equal counts keep the shuffled
|
||||||
|
// (random) order, giving a fair tie-break.
|
||||||
|
rng.Shuffle(len(eligible), func(i, j int) {
|
||||||
|
eligible[i], eligible[j] = eligible[j], eligible[i]
|
||||||
|
})
|
||||||
|
sort.SliceStable(eligible, func(i, j int) bool {
|
||||||
|
return eligible[i].DutyCount < eligible[j].DutyCount
|
||||||
|
})
|
||||||
|
|
||||||
|
return eligible[0].Name, eligible[1].Name, true
|
||||||
|
}
|
||||||
|
|
||||||
// parseWeekday maps an English weekday name (case-insensitive) to time.Weekday.
|
// parseWeekday maps an English weekday name (case-insensitive) to time.Weekday.
|
||||||
func parseWeekday(s string) (time.Weekday, error) {
|
func parseWeekday(s string) (time.Weekday, error) {
|
||||||
days := map[string]time.Weekday{
|
days := map[string]time.Weekday{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package dyzurbot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLiveReminderFire performs ONE real reminder fire against a live Telegram
|
||||||
|
// chat and the live Google Sheet, exercising the full scheduler send path:
|
||||||
|
// Syncer.Sync -> Store.Snapshot -> reminderText -> mention -> BotSendFunc.
|
||||||
|
//
|
||||||
|
// It is a manual smoke test, skipped unless DYZUR_LIVE_FIRE=1, and needs the same
|
||||||
|
// env the binary uses: BOT_TOKEN, GROUP_CHAT_ID, GOOGLE_CREDENTIALS_FILE, GOOGLE_SHEETS_ID
|
||||||
|
// (and ROTA_CACHE_PATH for the cache file).
|
||||||
|
//
|
||||||
|
// WARNING — this MUTATES the sheet. fire() announces the current week and, when
|
||||||
|
// that week is empty, auto-assigns two people and WRITES their names into the
|
||||||
|
// rota. Point GROUP_CHAT_ID at a SANDBOX chat AND GOOGLE_SHEETS_ID at a COPY of
|
||||||
|
// the sheet — otherwise this pings real members and edits the production rota.
|
||||||
|
func TestLiveReminderFire(t *testing.T) {
|
||||||
|
if os.Getenv("DYZUR_LIVE_FIRE") != "1" {
|
||||||
|
t.Skip("set DYZUR_LIVE_FIRE=1 to run the live reminder smoke test")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Reminder == nil {
|
||||||
|
t.Fatal("Reminder config is nil — set GROUP_CHAT_ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous sync so the store is populated before we fire (Service.Start
|
||||||
|
// would run the first sync in the background, racing the fire below).
|
||||||
|
svc := NewService(cfg)
|
||||||
|
if err := svc.Syncer.Sync(ctx); err != nil {
|
||||||
|
t.Fatalf("sheet sync: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := os.Getenv("BOT_TOKEN")
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("missing BOT_TOKEN")
|
||||||
|
}
|
||||||
|
b, err := NewBot(token, svc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc := *cfg.Reminder
|
||||||
|
assign := SheetAssigner(cfg.SpreadsheetID, cfg.SheetRange)
|
||||||
|
|
||||||
|
NewReminder(svc.Store, BotSendFunc(b, rc.ChatID), assign, rc).fire(ctx)
|
||||||
|
t.Logf("fired reminder to chat %d for the current week — verify it landed in the chat", rc.ChatID)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,216 @@
|
||||||
package dyzurbot
|
package dyzurbot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/rand"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type assignCall struct {
|
||||||
|
week DutyWeek
|
||||||
|
p1, p2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireHarness builds a Reminder over a fixed rota/now with capturing send and
|
||||||
|
// assign funcs, so fire()'s branches can be asserted without Telegram or Google.
|
||||||
|
type fireHarness struct {
|
||||||
|
sends []string
|
||||||
|
assigns []assignCall
|
||||||
|
assignFn AssignFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFireHarness(t *testing.T, rota Rota, now time.Time, seed int64) (*Reminder, *fireHarness) {
|
||||||
|
t.Helper()
|
||||||
|
h := &fireHarness{}
|
||||||
|
send := func(_ context.Context, text string) error {
|
||||||
|
h.sends = append(h.sends, text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
assign := func(ctx context.Context, week DutyWeek, p1, p2 string) error {
|
||||||
|
h.assigns = append(h.assigns, assignCall{week, p1, p2})
|
||||||
|
if h.assignFn != nil {
|
||||||
|
return h.assignFn(ctx, week, p1, p2)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r := &Reminder{
|
||||||
|
store: &Store{rota: rota},
|
||||||
|
send: send,
|
||||||
|
assign: assign,
|
||||||
|
cfg: ReminderConfig{Loc: warsaw(t)},
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
rng: rand.New(rand.NewSource(seed)),
|
||||||
|
}
|
||||||
|
return r, h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFire(t *testing.T) {
|
||||||
|
loc := warsaw(t)
|
||||||
|
mon := time.Date(2026, 6, 15, 9, 0, 0, 0, loc) // Mon; this week starts 2026-06-15
|
||||||
|
people := []Person{
|
||||||
|
{Name: "Ala", DutyCount: 1},
|
||||||
|
{Name: "Bartek", DutyCount: 2},
|
||||||
|
{Name: "Cela", DutyCount: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("this week empty -> assign two lowest and announce", func(t *testing.T) {
|
||||||
|
rota := Rota{
|
||||||
|
Weeks: []DutyWeek{
|
||||||
|
{WeekStart: day(t, "2026-06-15"), Row: 5}, // this: empty
|
||||||
|
{WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}, // next: filled
|
||||||
|
},
|
||||||
|
People: people,
|
||||||
|
}
|
||||||
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
|
r.fire(context.Background())
|
||||||
|
|
||||||
|
if len(h.assigns) != 1 {
|
||||||
|
t.Fatalf("assigns = %d, want 1", len(h.assigns))
|
||||||
|
}
|
||||||
|
a := h.assigns[0]
|
||||||
|
if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 5 {
|
||||||
|
t.Errorf("assign = %+v, want Ala/Bartek on row 5", a)
|
||||||
|
}
|
||||||
|
want := "Nikt nie zgłosił się na dyżur w tym tygodniu (od 2026-06-15), więc został przydzielony losowo: Ala i Bartek. Z góry dziękujemy za sprzątanie!"
|
||||||
|
if len(h.sends) != 1 || h.sends[0] != want {
|
||||||
|
t.Errorf("sends = %q, want [%q]", h.sends, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("this week filled, next week empty -> reminder + warning", func(t *testing.T) {
|
||||||
|
rota := Rota{
|
||||||
|
Weeks: []DutyWeek{
|
||||||
|
{WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5},
|
||||||
|
{WeekStart: day(t, "2026-06-22"), Row: 6}, // next: empty
|
||||||
|
},
|
||||||
|
People: people,
|
||||||
|
}
|
||||||
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
|
r.fire(context.Background())
|
||||||
|
|
||||||
|
if len(h.assigns) != 0 {
|
||||||
|
t.Errorf("assigns = %d, want 0 (this week already filled)", len(h.assigns))
|
||||||
|
}
|
||||||
|
wantReminder := "Przypomnienie: w tygodniu od 2026-06-15 dyżur sprzątają Jan i Anna. Proszę pamiętać o sprzątaniu."
|
||||||
|
wantWarning := "Dyżur na przyszły tydzień (od 2026-06-22) jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo."
|
||||||
|
if len(h.sends) != 2 || h.sends[0] != wantReminder || h.sends[1] != wantWarning {
|
||||||
|
t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantReminder, wantWarning)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("this week empty but fewer than 2 people -> no assign, no send", func(t *testing.T) {
|
||||||
|
rota := Rota{
|
||||||
|
Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}},
|
||||||
|
People: []Person{{Name: "Ala", DutyCount: 1}},
|
||||||
|
}
|
||||||
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
|
r.fire(context.Background())
|
||||||
|
|
||||||
|
if len(h.assigns) != 0 || len(h.sends) != 0 {
|
||||||
|
t.Errorf("assigns=%d sends=%q, want 0 and none", len(h.assigns), h.sends)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("this week empty but row unknown -> no assign, no send", func(t *testing.T) {
|
||||||
|
rota := Rota{
|
||||||
|
Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 0}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}},
|
||||||
|
People: people,
|
||||||
|
}
|
||||||
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
|
r.fire(context.Background())
|
||||||
|
|
||||||
|
if len(h.assigns) != 0 || len(h.sends) != 0 {
|
||||||
|
t.Errorf("assigns=%d sends=%q, want 0 and none (unwritable row)", len(h.assigns), h.sends)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("assign write fails -> no announcement", func(t *testing.T) {
|
||||||
|
rota := Rota{
|
||||||
|
Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}},
|
||||||
|
People: people,
|
||||||
|
}
|
||||||
|
r, h := newFireHarness(t, rota, mon, 1)
|
||||||
|
h.assignFn = func(context.Context, DutyWeek, string, string) error { return errors.New("boom") }
|
||||||
|
r.fire(context.Background())
|
||||||
|
|
||||||
|
if len(h.assigns) != 1 {
|
||||||
|
t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns))
|
||||||
|
}
|
||||||
|
if len(h.sends) != 0 {
|
||||||
|
t.Errorf("sends = %q, want none (write failed)", h.sends)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChooseTwo(t *testing.T) {
|
||||||
|
rng := rand.New(rand.NewSource(1))
|
||||||
|
|
||||||
|
t.Run("picks two lowest counts, lowest first", func(t *testing.T) {
|
||||||
|
people := []Person{
|
||||||
|
{Name: "Ala", DutyCount: 3},
|
||||||
|
{Name: "Bartek", DutyCount: 1},
|
||||||
|
{Name: "Cela", DutyCount: 2},
|
||||||
|
}
|
||||||
|
p1, p2, ok := chooseTwo(people, rng)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ok = false, want true")
|
||||||
|
}
|
||||||
|
if p1 != "Bartek" || p2 != "Cela" {
|
||||||
|
t.Errorf("got (%q, %q), want (Bartek, Cela)", p1, p2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignores blank-name entries", func(t *testing.T) {
|
||||||
|
people := []Person{
|
||||||
|
{Name: "", DutyCount: 0},
|
||||||
|
{Name: "Ala", DutyCount: 5},
|
||||||
|
{Name: "Bartek", DutyCount: 6},
|
||||||
|
}
|
||||||
|
p1, p2, ok := chooseTwo(people, rng)
|
||||||
|
if !ok || p1 != "Ala" || p2 != "Bartek" {
|
||||||
|
t.Errorf("got (%q, %q, %v), want (Ala, Bartek, true)", p1, p2, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fewer than two eligible -> ok=false", func(t *testing.T) {
|
||||||
|
if _, _, ok := chooseTwo([]Person{{Name: "Ala"}}, rng); ok {
|
||||||
|
t.Error("one person: ok = true, want false")
|
||||||
|
}
|
||||||
|
if _, _, ok := chooseTwo(nil, rng); ok {
|
||||||
|
t.Error("no people: ok = true, want false")
|
||||||
|
}
|
||||||
|
if _, _, ok := chooseTwo([]Person{{Name: ""}, {Name: ""}}, rng); ok {
|
||||||
|
t.Error("only blank names: ok = true, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("equal counts: random tie-break, distinct and deterministic per seed", func(t *testing.T) {
|
||||||
|
people := []Person{
|
||||||
|
{Name: "Ala", DutyCount: 1},
|
||||||
|
{Name: "Bartek", DutyCount: 1},
|
||||||
|
{Name: "Cela", DutyCount: 1},
|
||||||
|
}
|
||||||
|
p1, p2, ok := chooseTwo(people, rand.New(rand.NewSource(42)))
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ok = false, want true")
|
||||||
|
}
|
||||||
|
if p1 == p2 {
|
||||||
|
t.Errorf("chose the same person twice: %q", p1)
|
||||||
|
}
|
||||||
|
names := map[string]bool{"Ala": true, "Bartek": true, "Cela": true}
|
||||||
|
if !names[p1] || !names[p2] {
|
||||||
|
t.Errorf("got (%q, %q), both must be from the roster", p1, p2)
|
||||||
|
}
|
||||||
|
// Same seed reproduces the same choice.
|
||||||
|
q1, q2, _ := chooseTwo(people, rand.New(rand.NewSource(42)))
|
||||||
|
if q1 != p1 || q2 != p2 {
|
||||||
|
t.Errorf("not deterministic for a seed: (%q,%q) vs (%q,%q)", p1, p2, q1, q2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func warsaw(t *testing.T) *time.Location {
|
func warsaw(t *testing.T) *time.Location {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
loc, err := time.LoadLocation("Europe/Warsaw")
|
loc, err := time.LoadLocation("Europe/Warsaw")
|
||||||
|
|
@ -99,12 +305,12 @@ func TestReminderText(t *testing.T) {
|
||||||
{
|
{
|
||||||
"both people",
|
"both people",
|
||||||
rota("Ala", "Bartek"), target, true,
|
rota("Ala", "Bartek"), target, true,
|
||||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają Ala i Bartek.",
|
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają Ala i Bartek. Proszę pamiętać o sprzątaniu.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"one person",
|
"one person",
|
||||||
rota("Ala", ""), target, true,
|
rota("Ala", ""), target, true,
|
||||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta Ala.",
|
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta Ala. Proszę pamiętać o sprzątaniu.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"nobody assigned",
|
"nobody assigned",
|
||||||
|
|
@ -149,22 +355,22 @@ func TestReminderTextMentions(t *testing.T) {
|
||||||
{
|
{
|
||||||
"both have handles",
|
"both have handles",
|
||||||
rota("Ala", "Bartek"),
|
rota("Ala", "Bartek"),
|
||||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart.",
|
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart. Proszę pamiętać o sprzątaniu.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"one handle, one missing -> mixed",
|
"one handle, one missing -> mixed",
|
||||||
rota("Ala", "Cela"),
|
rota("Ala", "Cela"),
|
||||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela.",
|
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela. Proszę pamiętać o sprzątaniu.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name not in people list -> plain name",
|
"name not in people list -> plain name",
|
||||||
rota("Ala", "Zenon"),
|
rota("Ala", "Zenon"),
|
||||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon.",
|
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon. Proszę pamiętać o sprzątaniu.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"single person with handle",
|
"single person with handle",
|
||||||
rota("Bartek", ""),
|
rota("Bartek", ""),
|
||||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart.",
|
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart. Proszę pamiętać o sprzątaniu.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|
|
||||||
42
service.go
42
service.go
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
@ -44,6 +45,17 @@ func LoadConfig() (Config, error) {
|
||||||
return Config{}, fmt.Errorf("missing GOOGLE_SHEETS_ID in environment/.env")
|
return Config{}, fmt.Errorf("missing GOOGLE_SHEETS_ID in environment/.env")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail fast at startup if the service-account key is missing or unreadable,
|
||||||
|
// rather than on the first sheet fetch minutes later. ReadSheet consumes the
|
||||||
|
// path directly from the environment.
|
||||||
|
credPath := os.Getenv("GOOGLE_CREDENTIALS_FILE")
|
||||||
|
if credPath == "" {
|
||||||
|
return Config{}, fmt.Errorf("missing GOOGLE_CREDENTIALS_FILE in environment/.env")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(credPath); err != nil { // #nosec G703 -- credPath is operator-controlled config (env/.env)
|
||||||
|
return Config{}, fmt.Errorf("GOOGLE_CREDENTIALS_FILE %q: %w", credPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
raw := getenvDefault("SYNC_INTERVAL", "30m")
|
raw := getenvDefault("SYNC_INTERVAL", "30m")
|
||||||
interval, err := time.ParseDuration(raw)
|
interval, err := time.ParseDuration(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -90,15 +102,6 @@ func loadReminderConfig() (*ReminderConfig, error) {
|
||||||
return nil, fmt.Errorf("REMINDER_HOUR must be 0–23, got %d", hour)
|
return nil, fmt.Errorf("REMINDER_HOUR must be 0–23, got %d", hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
aheadRaw := getenvDefault("DUTY_AHEAD_DAYS", "14")
|
|
||||||
ahead, err := strconv.Atoi(aheadRaw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid DUTY_AHEAD_DAYS %q: %w", aheadRaw, err)
|
|
||||||
}
|
|
||||||
if ahead < 0 {
|
|
||||||
return nil, fmt.Errorf("DUTY_AHEAD_DAYS must be >= 0, got %d", ahead)
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err := time.LoadLocation(reminderTimezone)
|
loc, err := time.LoadLocation(reminderTimezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load timezone %q: %w", reminderTimezone, err)
|
return nil, fmt.Errorf("load timezone %q: %w", reminderTimezone, err)
|
||||||
|
|
@ -108,7 +111,6 @@ func loadReminderConfig() (*ReminderConfig, error) {
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Weekday: weekday,
|
Weekday: weekday,
|
||||||
Hour: hour,
|
Hour: hour,
|
||||||
AheadDays: ahead,
|
|
||||||
Loc: loc,
|
Loc: loc,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +226,26 @@ func SheetFetcher(spreadsheetID, readRange string) FetchFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveRange returns the explicit range when set, otherwise the default that
|
// 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").
|
// 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
|
// Resolving against now (not a startup-captured value) is what lets the range
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,31 @@ package dyzurbot
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestLoadConfigDefaults(t *testing.T) {
|
||||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||||
t.Setenv("SHEET_RANGE", "")
|
t.Setenv("SHEET_RANGE", "")
|
||||||
t.Setenv("ROTA_CACHE_PATH", "")
|
t.Setenv("ROTA_CACHE_PATH", "")
|
||||||
t.Setenv("SYNC_INTERVAL", "")
|
t.Setenv("SYNC_INTERVAL", "")
|
||||||
|
setCredFile(t)
|
||||||
|
|
||||||
cfg, err := LoadConfig()
|
cfg, err := LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -41,8 +56,25 @@ func TestLoadConfigRequiresSpreadsheetID(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestLoadConfigRejectsBadInterval(t *testing.T) {
|
||||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||||
|
setCredFile(t)
|
||||||
t.Setenv("SYNC_INTERVAL", "not-a-duration")
|
t.Setenv("SYNC_INTERVAL", "not-a-duration")
|
||||||
if _, err := LoadConfig(); err == nil {
|
if _, err := LoadConfig(); err == nil {
|
||||||
t.Fatal("expected error for unparseable SYNC_INTERVAL")
|
t.Fatal("expected error for unparseable SYNC_INTERVAL")
|
||||||
|
|
@ -51,6 +83,7 @@ func TestLoadConfigRejectsBadInterval(t *testing.T) {
|
||||||
|
|
||||||
func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) {
|
func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) {
|
||||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||||
|
setCredFile(t)
|
||||||
t.Setenv("SYNC_INTERVAL", "0s")
|
t.Setenv("SYNC_INTERVAL", "0s")
|
||||||
if _, err := LoadConfig(); err == nil {
|
if _, err := LoadConfig(); err == nil {
|
||||||
t.Fatal("expected error for non-positive SYNC_INTERVAL")
|
t.Fatal("expected error for non-positive SYNC_INTERVAL")
|
||||||
|
|
@ -74,7 +107,6 @@ func TestLoadReminderConfigParses(t *testing.T) {
|
||||||
t.Setenv("GROUP_CHAT_ID", "-5572351978")
|
t.Setenv("GROUP_CHAT_ID", "-5572351978")
|
||||||
t.Setenv("REMINDER_WEEKDAY", "Friday")
|
t.Setenv("REMINDER_WEEKDAY", "Friday")
|
||||||
t.Setenv("REMINDER_HOUR", "18")
|
t.Setenv("REMINDER_HOUR", "18")
|
||||||
t.Setenv("DUTY_AHEAD_DAYS", "7")
|
|
||||||
|
|
||||||
r, err := loadReminderConfig()
|
r, err := loadReminderConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -86,8 +118,8 @@ func TestLoadReminderConfigParses(t *testing.T) {
|
||||||
if r.ChatID != -5572351978 {
|
if r.ChatID != -5572351978 {
|
||||||
t.Errorf("ChatID = %d, want -5572351978", r.ChatID)
|
t.Errorf("ChatID = %d, want -5572351978", r.ChatID)
|
||||||
}
|
}
|
||||||
if r.Weekday != time.Friday || r.Hour != 18 || r.AheadDays != 7 {
|
if r.Weekday != time.Friday || r.Hour != 18 {
|
||||||
t.Errorf("got %v %d ahead=%d, want Friday 18 ahead=7", r.Weekday, r.Hour, r.AheadDays)
|
t.Errorf("got %v %d, want Friday 18", r.Weekday, r.Hour)
|
||||||
}
|
}
|
||||||
if r.Loc == nil || r.Loc.String() != "Europe/Warsaw" {
|
if r.Loc == nil || r.Loc.String() != "Europe/Warsaw" {
|
||||||
t.Errorf("Loc = %v, want Europe/Warsaw", r.Loc)
|
t.Errorf("Loc = %v, want Europe/Warsaw", r.Loc)
|
||||||
|
|
@ -98,14 +130,13 @@ func TestLoadReminderConfigDefaults(t *testing.T) {
|
||||||
t.Setenv("GROUP_CHAT_ID", "42")
|
t.Setenv("GROUP_CHAT_ID", "42")
|
||||||
t.Setenv("REMINDER_WEEKDAY", "")
|
t.Setenv("REMINDER_WEEKDAY", "")
|
||||||
t.Setenv("REMINDER_HOUR", "")
|
t.Setenv("REMINDER_HOUR", "")
|
||||||
t.Setenv("DUTY_AHEAD_DAYS", "")
|
|
||||||
|
|
||||||
r, err := loadReminderConfig()
|
r, err := loadReminderConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("loadReminderConfig: %v", err)
|
t.Fatalf("loadReminderConfig: %v", err)
|
||||||
}
|
}
|
||||||
if r.Weekday != time.Monday || r.Hour != 9 || r.AheadDays != 14 {
|
if r.Weekday != time.Monday || r.Hour != 9 {
|
||||||
t.Errorf("defaults: got %v %d ahead=%d, want Monday 9 ahead=14", r.Weekday, r.Hour, r.AheadDays)
|
t.Errorf("defaults: got %v %d, want Monday 9", r.Weekday, r.Hour)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +149,6 @@ func TestLoadReminderConfigRejects(t *testing.T) {
|
||||||
{"bad chat id", "GROUP_CHAT_ID", "not-a-number"},
|
{"bad chat id", "GROUP_CHAT_ID", "not-a-number"},
|
||||||
{"hour too high", "REMINDER_HOUR", "24"},
|
{"hour too high", "REMINDER_HOUR", "24"},
|
||||||
{"hour negative", "REMINDER_HOUR", "-1"},
|
{"hour negative", "REMINDER_HOUR", "-1"},
|
||||||
{"ahead negative", "DUTY_AHEAD_DAYS", "-1"},
|
|
||||||
{"bad weekday", "REMINDER_WEEKDAY", "Funday"},
|
{"bad weekday", "REMINDER_WEEKDAY", "Funday"},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|
@ -126,7 +156,6 @@ func TestLoadReminderConfigRejects(t *testing.T) {
|
||||||
t.Setenv("GROUP_CHAT_ID", "42") // enabled by default; case may override
|
t.Setenv("GROUP_CHAT_ID", "42") // enabled by default; case may override
|
||||||
t.Setenv("REMINDER_WEEKDAY", "Monday")
|
t.Setenv("REMINDER_WEEKDAY", "Monday")
|
||||||
t.Setenv("REMINDER_HOUR", "9")
|
t.Setenv("REMINDER_HOUR", "9")
|
||||||
t.Setenv("DUTY_AHEAD_DAYS", "14")
|
|
||||||
t.Setenv(tc.key, tc.val)
|
t.Setenv(tc.key, tc.val)
|
||||||
|
|
||||||
if _, err := loadReminderConfig(); err == nil {
|
if _, err := loadReminderConfig(); err == nil {
|
||||||
|
|
@ -152,6 +181,19 @@ func TestSyncSuccessReplacesCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAssignRange(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 19, 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)
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveRange(t *testing.T) {
|
func TestResolveRange(t *testing.T) {
|
||||||
now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
|
|
||||||
64
sheet.go
64
sheet.go
|
|
@ -28,6 +28,10 @@ type DutyWeek struct {
|
||||||
WeekStart time.Time // Monday that starts the week
|
WeekStart time.Time // Monday that starts the week
|
||||||
Person1 string // Osoba 1 — "" if unassigned
|
Person1 string // Osoba 1 — "" if unassigned
|
||||||
Person2 string // Osoba 2 — "" if unassigned
|
Person2 string // Osoba 2 — "" if unassigned
|
||||||
|
// Row is the 1-based sheet row this week occupies, used to address its
|
||||||
|
// cells when writing an assignment back. Valid only when the read range
|
||||||
|
// starts at A1 (as resolveRange always produces); 0 means unknown.
|
||||||
|
Row int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Person is a single entry from the people list (columns E–G).
|
// Person is a single entry from the people list (columns E–G).
|
||||||
|
|
@ -43,20 +47,37 @@ type Rota struct {
|
||||||
People []Person
|
People []Person
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadSheet reads the given range from the sheet and returns its rows.
|
// newSheetsService builds an authenticated Sheets client using the
|
||||||
// spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30".
|
// service-account key file named by GOOGLE_CREDENTIALS_FILE (loaded into the
|
||||||
// The API key is read from GOOGLE_API_KEY (loaded into the environment by
|
// environment by LoadConfig, which calls godotenv.Load at startup). The
|
||||||
// LoadConfig, which calls godotenv.Load at startup).
|
// spreadsheets scope grants read+write; the sheet must be shared with the
|
||||||
func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) {
|
// service account's client_email for either to work.
|
||||||
apiKey := os.Getenv("GOOGLE_API_KEY")
|
func newSheetsService(ctx context.Context) (*sheets.Service, error) {
|
||||||
if apiKey == "" {
|
credPath := os.Getenv("GOOGLE_CREDENTIALS_FILE")
|
||||||
return nil, fmt.Errorf("missing GOOGLE_API_KEY in environment/.env")
|
if credPath == "" {
|
||||||
|
return nil, fmt.Errorf("missing GOOGLE_CREDENTIALS_FILE in environment/.env")
|
||||||
}
|
}
|
||||||
|
|
||||||
srv, err := sheets.NewService(ctx, option.WithAPIKey(apiKey))
|
// WithAuthCredentialsFile pins the credential type to ServiceAccount, which
|
||||||
|
// is the non-deprecated replacement for WithCredentialsFile: it refuses an
|
||||||
|
// unexpected credential type instead of loading it blindly.
|
||||||
|
srv, err := sheets.NewService(ctx,
|
||||||
|
option.WithAuthCredentialsFile(option.ServiceAccount, credPath),
|
||||||
|
option.WithScopes(sheets.SpreadsheetsScope), // read+write
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init sheets service: %w", err)
|
return nil, fmt.Errorf("init sheets service: %w", err)
|
||||||
}
|
}
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSheet reads the given range from the sheet and returns its rows.
|
||||||
|
// spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30".
|
||||||
|
func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) {
|
||||||
|
srv, err := newSheetsService(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := srv.Spreadsheets.Values.
|
resp, err := srv.Spreadsheets.Values.
|
||||||
Get(spreadsheetID, readRange).
|
Get(spreadsheetID, readRange).
|
||||||
|
|
@ -70,6 +91,30 @@ func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interf
|
||||||
return resp.Values, nil
|
return resp.Values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteSheet overwrites the cells starting at writeRange with values. writeRange
|
||||||
|
// anchors the top-left cell (e.g. "Dyżury!G2"); values is row-major, so each
|
||||||
|
// inner slice is one row written left-to-right. RAW input stores strings and
|
||||||
|
// numbers verbatim — a leading "=" is not interpreted as a formula. The
|
||||||
|
// service-account credential must have write access and the sheet be shared with
|
||||||
|
// it (see newSheetsService).
|
||||||
|
func WriteSheet(ctx context.Context, spreadsheetID, writeRange string, values [][]interface{}) error {
|
||||||
|
srv, err := newSheetsService(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = srv.Spreadsheets.Values.
|
||||||
|
Update(spreadsheetID, writeRange, &sheets.ValueRange{Values: values}).
|
||||||
|
ValueInputOption("RAW").
|
||||||
|
Context(ctx).
|
||||||
|
Do()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update values %q: %w", writeRange, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ParseRota interprets the raw rows from ReadSheet as two independent tables:
|
// ParseRota interprets the raw rows from ReadSheet as two independent tables:
|
||||||
// the duty schedule (columns A–C) and the people list (columns E–G).
|
// the duty schedule (columns A–C) and the people list (columns E–G).
|
||||||
// The header row is skipped; malformed cells are ignored, not reported.
|
// The header row is skipped; malformed cells are ignored, not reported.
|
||||||
|
|
@ -88,6 +133,7 @@ func ParseRota(rows [][]interface{}) Rota {
|
||||||
WeekStart: serialToDate(serial),
|
WeekStart: serialToDate(serial),
|
||||||
Person1: cellString(row, colPerson1),
|
Person1: cellString(row, colPerson1),
|
||||||
Person2: cellString(row, colPerson2),
|
Person2: cellString(row, colPerson2),
|
||||||
|
Row: i + 1, // slice index -> 1-based sheet row (range starts at A1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,19 @@ func TestParseRotaDecodesWeekStartToMonday(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRotaTracksSheetRow(t *testing.T) {
|
||||||
|
rota := ParseRota(sampleRows())
|
||||||
|
|
||||||
|
// The read range starts at A1, so a week at slice index i sits on sheet row
|
||||||
|
// i+1. The four weeks come from indices 1..4 -> rows 2..5.
|
||||||
|
wantRows := []int{2, 3, 4, 5}
|
||||||
|
for i, want := range wantRows {
|
||||||
|
if got := rota.Weeks[i].Row; got != want {
|
||||||
|
t.Errorf("week %d: Row = %d, want %d", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRotaWeekPersons(t *testing.T) {
|
func TestParseRotaWeekPersons(t *testing.T) {
|
||||||
rota := ParseRota(sampleRows())
|
rota := ParseRota(sampleRows())
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue