From 74f3c030130971276521004ac0ba6846f4f7e8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20=27Kama=C5=9B=27=20Bruchal?= Date: Wed, 17 Jun 2026 20:31:53 +0200 Subject: [PATCH] feat: add Syncer that refreshes cache and tolerates fetch failure --- sync.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ sync_test.go | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 sync.go create mode 100644 sync_test.go diff --git a/sync.go b/sync.go new file mode 100644 index 0000000..2b31555 --- /dev/null +++ b/sync.go @@ -0,0 +1,59 @@ +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 cancelled. +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 new file mode 100644 index 0000000..87d346a --- /dev/null +++ b/sync_test.go @@ -0,0 +1,44 @@ +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) + } +}