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>
199 lines
5.7 KiB
Go
199 lines
5.7 KiB
Go
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 }
|