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

View File

@@ -0,0 +1,198 @@
package membership
import (
"context"
"fuj-management/go/internal/config"
"fuj-management/go/internal/io/attendance"
"fuj-management/go/internal/io/cache"
"fuj-management/go/internal/io/drive"
"fuj-management/go/internal/io/sheets"
"testing"
"time"
)
// buildSources wires a realSources with in-memory fakes and a no-TTL cache.
func buildSources(t *testing.T, att *attendance.Fake, sh *sheets.Fake) *realSources {
t.Helper()
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{
config.AttendanceSheetID: "t1",
config.PaymentsSheetID: "t1",
}}
fc := cache.New(d, dir, config.CacheSheetMap, 0, 0)
return &realSources{attendance: att, sheets: sh, cache: fc}
}
var minimalAdultCSV = [][]string{
{"Jméno", "Tier", "", "", "01.09.2025", "08.09.2025"},
{"Alice", "A", "", "", "TRUE", "TRUE"},
{"Bob", "A", "", "", "TRUE", "FALSE"},
{"# last line"},
}
// minimalJuniorCSV has dates in October because the junior merged-month map sends
// 2025-09 → 2025-10, so two columns for 01.10.2025 and 08.10.2025 land in "2025-10".
var minimalJuniorCSV = [][]string{
{"Jméno", "Tier", "", "", "01.10.2025", "08.10.2025"},
{"Charlie", "J", "", "", "TRUE", "TRUE"},
{"# Trenéři"},
{"Coach", "X", "", "", "FALSE", "FALSE"},
}
func TestLoadAdults(t *testing.T) {
s := buildSources(t, &attendance.Fake{Adults: minimalAdultCSV}, &sheets.Fake{})
members, months, err := s.LoadAdults(context.Background())
if err != nil {
t.Fatal(err)
}
// adultMergedMonths is empty so 2025-09 stays as-is
if len(months) != 1 || months[0] != "2025-09" {
t.Errorf("unexpected months: %v", months)
}
if len(members) != 2 {
t.Fatalf("want 2 members, got %d", len(members))
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Attendance
}
if byName["Alice"] != 2 {
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
}
if byName["Bob"] != 1 {
t.Errorf("Bob: want 1 session, got %d", byName["Bob"])
}
}
func TestLoadAdults_Fee(t *testing.T) {
s := buildSources(t, &attendance.Fake{Adults: minimalAdultCSV}, &sheets.Fake{})
members, _, err := s.LoadAdults(context.Background())
if err != nil {
t.Fatal(err)
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Expected
}
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750
if byName["Alice"] != 750 {
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
}
// 1 session → AdultFeeSingle = 200
if byName["Bob"] != 200 {
t.Errorf("Bob fee: want 200, got %d", byName["Bob"])
}
}
func TestLoadJuniors(t *testing.T) {
s := buildSources(t,
&attendance.Fake{Adults: minimalAdultCSV, Juniors: minimalJuniorCSV},
&sheets.Fake{})
members, months, err := s.LoadJuniors(context.Background())
if err != nil {
t.Fatal(err)
}
if len(months) == 0 {
t.Fatal("want months, got none")
}
found := false
for _, m := range members {
if m.Name == "Charlie" {
found = true
// Charlie has 2 sessions in 2025-10 (October dates in junior CSV)
if m.Fees["2025-10"].Attendance != 2 {
t.Errorf("Charlie 2025-10 attendance: want 2, got %d", m.Fees["2025-10"].Attendance)
}
}
}
if !found {
t.Error("Charlie not found in juniors")
}
}
func TestLoadTransactions(t *testing.T) {
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
paymentsKey := config.PaymentsSheetID + "/A1:Z"
sh := &sheets.Fake{Values: map[string][][]any{
paymentsKey: {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
},
}}
s := buildSources(t, &attendance.Fake{}, sh)
txns, err := s.LoadTransactions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(txns) != 2 {
t.Fatalf("want 2 transactions, got %d", len(txns))
}
if txns[0].Person != "Alice" {
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
}
if txns[0].Amount != 700 {
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
}
}
func TestLoadExceptions(t *testing.T) {
excKey := config.PaymentsSheetID + "/'exceptions'!A2:D"
sh := &sheets.Fake{Values: map[string][][]any{
excKey: {
{"Alice", "2026-04", 350, "reduced"},
},
}}
s := buildSources(t, &attendance.Fake{}, sh)
exc, err := s.LoadExceptions(context.Background())
if err != nil {
t.Fatal(err)
}
if len(exc) != 1 {
t.Fatalf("want 1 exception, got %d", len(exc))
}
for k, v := range exc {
if v.Amount != 350 {
t.Errorf("exception amount: want 350, got %d (key=%v)", v.Amount, k)
}
if v.Note != "reduced" {
t.Errorf("exception note: want 'reduced', got %q", v.Note)
}
}
}
// TTL smoke test: second call within TTL must not call fetch again.
func TestLoadAdults_CacheHit(t *testing.T) {
dir := t.TempDir()
d := &drive.Fake{Times: map[string]string{config.AttendanceSheetID: "t1"}}
fc := cache.New(d, dir, config.CacheSheetMap, time.Minute, time.Minute)
calls := 0
att := &countingFetcher{rows: minimalAdultCSV, calls: &calls}
s := &realSources{attendance: att, sheets: &sheets.Fake{}, cache: fc}
if _, _, err := s.LoadAdults(context.Background()); err != nil {
t.Fatal(err)
}
if _, _, err := s.LoadAdults(context.Background()); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Errorf("want 1 fetch (cache hit on 2nd call), got %d", calls)
}
}
type countingFetcher struct {
rows [][]string
calls *int
}
func (f *countingFetcher) FetchAdults(_ context.Context) ([][]string, error) {
*f.calls++
return f.rows, nil
}
func (f *countingFetcher) FetchJuniors(_ context.Context) ([][]string, error) { return nil, nil }