diff --git a/fetch-sheet.go b/fetch-sheet.go deleted file mode 100644 index d5f7dc3..0000000 --- a/fetch-sheet.go +++ /dev/null @@ -1,41 +0,0 @@ -package dyzurbot - -import ( - "context" - "fmt" - "os" - - "github.com/joho/godotenv" - "google.golang.org/api/option" - "google.golang.org/api/sheets/v4" -) - -// ReadSheet czyta zadany zakres z arkusza i zwraca wiersze. -// spreadsheetID to ID z URL-a arkusza, readRange np. "Dyżury!A1:C30". -// Klucz API jest wczytywany z pliku .env (zmienna GOOGLE_API_KEY). -func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) { - // godotenv.Load nie nadpisuje już ustawionych zmiennych środowiskowych; - // brak pliku .env nie jest błędem (na produkcji zmienne mogą być ustawione inaczej). - _ = godotenv.Load() - - apiKey := os.Getenv("GOOGLE_API_KEY") - if apiKey == "" { - return nil, fmt.Errorf("missing GOOGLE_API_KEY in environment/.env") - } - - srv, err := sheets.NewService(ctx, option.WithAPIKey(apiKey)) - if err != nil { - return nil, fmt.Errorf("init sheets service: %w", err) - } - - resp, err := srv.Spreadsheets.Values. - Get(spreadsheetID, readRange). - ValueRenderOption("UNFORMATTED_VALUE"). // surowe wartości, bez formatowania komórek - Context(ctx). - Do() - if err != nil { - return nil, fmt.Errorf("get values %q: %w", readRange, err) - } - - return resp.Values, nil -} diff --git a/service.go b/service.go index 9aec79e..25d92c4 100644 --- a/service.go +++ b/service.go @@ -3,6 +3,7 @@ package dyzurbot import ( "context" "fmt" + "log" "os" "time" @@ -55,6 +56,58 @@ func getenvDefault(key, fallback string) string { return fallback } +// FetchFunc retrieves and parses the current rota from upstream (the Sheet). +type FetchFunc func(ctx context.Context) (Rota, error) + +// Syncer refreshes a Store from an upstream fetch. +type Syncer struct { + store *Store + fetch FetchFunc + now func() time.Time +} + +// NewSyncer wires a Store to a fetch function. +func NewSyncer(store *Store, fetch FetchFunc) *Syncer { + return &Syncer{store: store, fetch: fetch, now: time.Now} +} + +// Sync fetches once. On success it replaces the cache; on failure it logs a +// warning, leaves the last good cache in place, and returns the error so a +// caller (e.g. a manual /sync command) can report it. +func (sy *Syncer) Sync(ctx context.Context) error { + rota, err := sy.fetch(ctx) + if err != nil { + log.Printf("sync: fetch failed, keeping cached data: %v", err) + return err + } + return sy.store.Replace(rota, sy.now()) +} + +// Run polls Sync on the given interval until ctx is canceled. +func (sy *Syncer) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _ = sy.Sync(ctx) + } + } +} + +// SheetFetcher builds a FetchFunc that reads the rota from Google Sheets. +func SheetFetcher(spreadsheetID, readRange string) FetchFunc { + return func(ctx context.Context) (Rota, error) { + rows, err := ReadSheet(ctx, spreadsheetID, readRange) + if err != nil { + return Rota{}, err + } + return ParseRota(rows), nil + } +} + // Service ties the Store and Syncer together for the bot to consume. type Service struct { Store *Store diff --git a/service_test.go b/service_test.go index 9711797..e93d0d2 100644 --- a/service_test.go +++ b/service_test.go @@ -1,7 +1,10 @@ package dyzurbot import ( + "context" + "errors" "fmt" + "path/filepath" "testing" "time" ) @@ -53,3 +56,38 @@ func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) { t.Fatal("expected error for non-positive SYNC_INTERVAL") } } + +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) + } +} + +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) + } +} diff --git a/sync.go b/sync.go deleted file mode 100644 index ae40ced..0000000 --- a/sync.go +++ /dev/null @@ -1,59 +0,0 @@ -package dyzurbot - -import ( - "context" - "log" - "time" -) - -// FetchFunc retrieves and parses the current rota from upstream (the Sheet). -type FetchFunc func(ctx context.Context) (Rota, error) - -// Syncer refreshes a Store from an upstream fetch. -type Syncer struct { - store *Store - fetch FetchFunc - now func() time.Time -} - -// NewSyncer wires a Store to a fetch function. -func NewSyncer(store *Store, fetch FetchFunc) *Syncer { - return &Syncer{store: store, fetch: fetch, now: time.Now} -} - -// Sync fetches once. On success it replaces the cache; on failure it logs a -// warning, leaves the last good cache in place, and returns the error so a -// caller (e.g. a manual /sync command) can report it. -func (sy *Syncer) Sync(ctx context.Context) error { - rota, err := sy.fetch(ctx) - if err != nil { - log.Printf("sync: fetch failed, keeping cached data: %v", err) - return err - } - return sy.store.Replace(rota, sy.now()) -} - -// Run polls Sync on the given interval until ctx is canceled. -func (sy *Syncer) Run(ctx context.Context, interval time.Duration) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - _ = sy.Sync(ctx) - } - } -} - -// SheetFetcher builds a FetchFunc that reads the rota from Google Sheets. -func SheetFetcher(spreadsheetID, readRange string) FetchFunc { - return func(ctx context.Context) (Rota, error) { - rows, err := ReadSheet(ctx, spreadsheetID, readRange) - if err != nil { - return Rota{}, err - } - return ParseRota(rows), nil - } -} diff --git a/sync_test.go b/sync_test.go deleted file mode 100644 index 87d346a..0000000 --- a/sync_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package dyzurbot - -import ( - "context" - "errors" - "path/filepath" - "testing" - "time" -) - -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) - } -} - -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) - } -}