feat(go): IO layer behind interfaces (M4)
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s

- io/attendance: CSV-over-public-URL client + Fake for adult/junior tabs
- io/drive: Drive v3 modifiedTime client + Fake
- io/sheets: Sheets v4 client (GetValues/AppendValues/BatchUpdateValues/
  WriteHeader/SortByDateColumn) + Fake with call-capture
- io/cache: Drive-modifiedTime-gated FileCache; two TTL knobs; atomic
  writes; generic Get[T]; Python-compatible JSON format; Flush()
- io/fio: Client interface backed by Fio REST API (apiClient) and HTML
  scraper (transparentClient); Fake; testdata fixtures
- membership/sources: NewSources wires attendance CSV + Sheets + cache
  into LoadAdults/LoadJuniors/LoadTransactions/LoadExceptions; Czech
  month parsing + merged-month maps
- banksync: SyncToSheets (SHA-256 dedup, optional sort) and
  InferPayments ([?] review prefix, dry-run) — tested with fakes
- cmd/fuj: sync and infer subcommands wired; fees and reconcile use
  real NewSources; go.mod gains google.golang.org/api + x/net
- gofumpt extra-rules applied across all packages; lint clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 01:05:59 +02:00
parent 7afd12d9a5
commit 6465e2a221
45 changed files with 3292 additions and 46 deletions

125
go/internal/io/cache/filecache_test.go vendored Normal file
View File

@@ -0,0 +1,125 @@
package cache
import (
"context"
"errors"
"fuj-management/go/internal/io/drive"
"os"
"testing"
"time"
)
func TestGet_FreshFetch(t *testing.T) {
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{"sheet1": "2026-01-01T00:00:00Z"}}
fc := New(d, dir, map[string]string{"mykey": "sheet1"}, time.Minute, time.Minute)
calls := 0
got, err := Get(context.Background(), fc, "mykey", func(_ context.Context) ([]string, error) {
calls++
return []string{"a", "b"}, nil
})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0] != "a" {
t.Errorf("unexpected: %v", got)
}
if calls != 1 {
t.Errorf("want 1 fetch call, got %d", calls)
}
}
func TestGet_CacheHit(t *testing.T) {
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{"sheet1": "2026-01-01T00:00:00Z"}}
fc := New(d, dir, map[string]string{"mykey": "sheet1"}, time.Minute, time.Minute)
fetch := func(_ context.Context) ([]string, error) { return []string{"a"}, nil }
if _, err := Get(context.Background(), fc, "mykey", fetch); err != nil {
t.Fatal(err)
}
// Second call — modifiedTime unchanged, should hit cache
calls := 0
got, err := Get(context.Background(), fc, "mykey", func(_ context.Context) ([]string, error) {
calls++
return []string{"SHOULD_NOT_CALL"}, nil
})
if err != nil {
t.Fatal(err)
}
if got[0] != "a" {
t.Errorf("want cache hit with 'a', got %q", got[0])
}
if calls != 0 {
t.Errorf("want 0 fetch calls on hit, got %d", calls)
}
}
func TestGet_CacheMiss_OnModifiedTimeChange(t *testing.T) {
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{"sheet1": "2026-01-01T00:00:00Z"}}
// No TTL guards so we always hit Drive
fc := New(d, dir, map[string]string{"mykey": "sheet1"}, 0, 0)
fetch := func(_ context.Context) ([]string, error) { return []string{"v1"}, nil }
if _, err := Get(context.Background(), fc, "mykey", fetch); err != nil {
t.Fatal(err)
}
// Sheet updated — change modifiedTime
d.Times["sheet1"] = "2026-02-01T00:00:00Z"
got, err := Get(context.Background(), fc, "mykey", func(_ context.Context) ([]string, error) {
return []string{"v2"}, nil
})
if err != nil {
t.Fatal(err)
}
if got[0] != "v2" {
t.Errorf("want v2 after sheet update, got %q", got[0])
}
}
func TestGet_DriveFailureFallback(t *testing.T) {
dir := t.TempDir()
d := &drive.Fake{Err: errors.New("drive down")}
fc := New(d, dir, nil, 0, 0)
calls := 0
_, err := Get(context.Background(), fc, "mykey", func(_ context.Context) ([]string, error) {
calls++
return []string{"fallback"}, nil
})
if err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Errorf("want 1 fetch call, got %d", calls)
}
}
func TestFlush(t *testing.T) {
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{"sheet1": "t1"}}
fc := New(d, dir, map[string]string{"k": "sheet1"}, 0, 0)
if _, err := Get(context.Background(), fc, "k", func(_ context.Context) (int, error) { return 42, nil }); err != nil {
t.Fatal(err)
}
n, err := fc.Flush()
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("want 1 deleted file, got %d", n)
}
// Cache dir should be empty of _cache.json files
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if e.Name() != "" {
t.Errorf("expected empty dir after flush, found %s", e.Name())
}
}
}