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 "/" — 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 }