refactor: files consolidation
This commit is contained in:
parent
cb0cc679c8
commit
a7bbbdc084
|
|
@ -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
|
||||
}
|
||||
53
service.go
53
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
sync.go
59
sync.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
44
sync_test.go
44
sync_test.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue