All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Two regressions made older periods invisible on the adults dashboard: -1257f0d(Mar 9) commented out ADULT_MERGED_MONTHS, removing the Sep+Oct 2025 merged label. Restored only the 2025-09 → 2025-10 mapping (Dec and Jan are billed separately for adults; the Dec → Jan mapping stays disabled per product decision). Mirrored on the Go side. Test fixtures in sources_test.go now assert Sep dates land in merged 2025-10 instead of 2025-09. -7774301(Apr 9) added a JS onload default that set the From selector to maxMonthIdx − 4 and immediately filtered the table, hiding everything older than 5 months on first load. Dropped that default in templates/adults.html and templates/juniors.html so the From-selector starts at the oldest available month. Future months are still removed from the dropdowns and hidden in the table — only the past-month truncation is gone. Note: the live adults attendance sheet had also been pruned to start at 02.12.2025; restoring Sep/Oct/Nov 2025 columns from Sheets version history is required to actually see those periods. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
230 lines
7.2 KiB
Go
230 lines
7.2 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 sends 2025-09 → 2025-10
|
|
if len(months) != 1 || months[0] != "2025-10" {
|
|
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-10"].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-10"].Expected
|
|
}
|
|
// 2 sessions land in merged 2025-10 → AdultFeeMonthlyRate["2025-10"] = 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.
|
|
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
|
|
// serial-day form (float64) — the API returns either depending on cell
|
|
// formatting, and FormatDate must handle both.
|
|
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"},
|
|
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
|
|
},
|
|
}}
|
|
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)
|
|
}
|
|
if txns[0].Date != "2026-04-01" {
|
|
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
|
|
}
|
|
if txns[1].Date != "2026-05-05" {
|
|
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
|
|
// "02.01.2006" format dropped header cells written without leading zeros
|
|
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
|
|
// months on the /api/juniors response. Czech sheet authors drop the zero
|
|
// pad freely; Python's strptime tolerates it, so the parsers must match.
|
|
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
|
|
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
|
|
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
|
|
// strptime branch — month-first, day-second.
|
|
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
|
|
got := parseDates(header)
|
|
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
|
|
}
|
|
for i, e := range got {
|
|
if e.month != want[i] {
|
|
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 }
|