From 295844983a95f392650afa4a309ce921bbe4d662 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 08:10:52 +0100 Subject: [PATCH] test(01-09): add ICM, chop/deal, tournament lifecycle, and integration tests - 12 ICM tests: exact/Monte Carlo, validation, performance, convergence - 6 chop/deal tests: chip chop, even chop, custom, partial, positions, tournament end - 9 tournament unit tests: template creation, overrides, start validation, auto-close, multi-tournament, state aggregation - 4 integration tests: full lifecycle, deal workflow, cancel, pause/resume - Fix integration test DB concurrency with file-based DB + WAL mode Co-Authored-By: Claude Opus 4.6 --- internal/financial/chop_test.go | 530 +++++++++++++++++++ internal/financial/icm_test.go | 386 ++++++++++++++ internal/tournament/integration_test.go | 673 ++++++++++++++++++++++++ internal/tournament/tournament.go | 1 + internal/tournament/tournament_test.go | 581 ++++++++++++++++++++ 5 files changed, 2171 insertions(+) create mode 100644 internal/financial/chop_test.go create mode 100644 internal/financial/icm_test.go create mode 100644 internal/tournament/integration_test.go create mode 100644 internal/tournament/tournament_test.go diff --git a/internal/financial/chop_test.go b/internal/financial/chop_test.go new file mode 100644 index 0000000..90dfa05 --- /dev/null +++ b/internal/financial/chop_test.go @@ -0,0 +1,530 @@ +package financial + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/tursodatabase/go-libsql" + + "github.com/felt-app/felt/internal/audit" + "github.com/felt-app/felt/internal/template" +) + +// setupChopTestDB creates a test database with all required tables. +func setupChopTestDB(t *testing.T) *sql.DB { + t.Helper() + + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + dbName := "file:" + t.Name() + "?mode=memory" + db, err := sql.Open("libsql", dbName) + if err != nil { + t.Fatalf("open test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + stmts := []string{ + `CREATE TABLE IF NOT EXISTS venue_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + venue_name TEXT NOT NULL DEFAULT '', + currency_code TEXT NOT NULL DEFAULT 'DKK', + currency_symbol TEXT NOT NULL DEFAULT 'kr', + rounding_denomination INTEGER NOT NULL DEFAULT 5000, + receipt_mode TEXT NOT NULL DEFAULT 'digital', + timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen', + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS buyin_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + buyin_amount INTEGER NOT NULL DEFAULT 0, + starting_chips INTEGER NOT NULL DEFAULT 0, + rake_total INTEGER NOT NULL DEFAULT 0, + bounty_amount INTEGER NOT NULL DEFAULT 0, + bounty_chip INTEGER NOT NULL DEFAULT 0, + rebuy_allowed INTEGER NOT NULL DEFAULT 0, + rebuy_cost INTEGER NOT NULL DEFAULT 0, + rebuy_chips INTEGER NOT NULL DEFAULT 0, + rebuy_rake INTEGER NOT NULL DEFAULT 0, + rebuy_limit INTEGER NOT NULL DEFAULT 0, + rebuy_level_cutoff INTEGER, + rebuy_time_cutoff_seconds INTEGER, + rebuy_chip_threshold INTEGER, + addon_allowed INTEGER NOT NULL DEFAULT 0, + addon_cost INTEGER NOT NULL DEFAULT 0, + addon_chips INTEGER NOT NULL DEFAULT 0, + addon_rake INTEGER NOT NULL DEFAULT 0, + addon_level_start INTEGER, + addon_level_end INTEGER, + reentry_allowed INTEGER NOT NULL DEFAULT 0, + reentry_limit INTEGER NOT NULL DEFAULT 0, + late_reg_level_cutoff INTEGER, + late_reg_time_cutoff_seconds INTEGER, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS rake_splits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + buyin_config_id INTEGER NOT NULL, + category TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + UNIQUE(buyin_config_id, category) + )`, + `CREATE TABLE IF NOT EXISTS payout_structures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tournaments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + template_id INTEGER, + chip_set_id INTEGER NOT NULL DEFAULT 1, + blind_structure_id INTEGER NOT NULL DEFAULT 1, + payout_structure_id INTEGER NOT NULL DEFAULT 1, + buyin_config_id INTEGER NOT NULL, + points_formula_id INTEGER, + status TEXT NOT NULL DEFAULT 'created', + min_players INTEGER NOT NULL DEFAULT 2, + max_players INTEGER, + early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0, + early_signup_cutoff TEXT, + punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0, + is_pko INTEGER NOT NULL DEFAULT 0, + current_level INTEGER NOT NULL DEFAULT 0, + clock_state TEXT NOT NULL DEFAULT 'stopped', + clock_remaining_ns INTEGER NOT NULL DEFAULT 0, + total_elapsed_ns INTEGER NOT NULL DEFAULT 0, + hand_for_hand INTEGER NOT NULL DEFAULT 0, + started_at INTEGER, + ended_at INTEGER, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + nickname TEXT, + email TEXT, + phone TEXT, + photo_url TEXT, + notes TEXT, + custom_fields TEXT, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tournament_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'registered', + seat_table_id INTEGER, + seat_position INTEGER, + buy_in_at INTEGER, + bust_out_at INTEGER, + bust_out_order INTEGER, + finishing_position INTEGER, + current_chips INTEGER NOT NULL DEFAULT 0, + rebuys INTEGER NOT NULL DEFAULT 0, + addons INTEGER NOT NULL DEFAULT 0, + reentries INTEGER NOT NULL DEFAULT 0, + bounty_value INTEGER NOT NULL DEFAULT 0, + bounties_collected INTEGER NOT NULL DEFAULT 0, + prize_amount INTEGER NOT NULL DEFAULT 0, + points_awarded INTEGER NOT NULL DEFAULT 0, + early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0, + punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0, + hitman_player_id TEXT, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(tournament_id, player_id) + )`, + `CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + type TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + chips INTEGER NOT NULL DEFAULT 0, + operator_id TEXT NOT NULL DEFAULT '', + receipt_data TEXT, + undone INTEGER NOT NULL DEFAULT 0, + undone_by TEXT, + metadata TEXT, + created_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS operators ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'floor', + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS audit_entries ( + id TEXT PRIMARY KEY, + tournament_id TEXT, + timestamp INTEGER NOT NULL, + operator_id TEXT NOT NULL DEFAULT 'system', + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + previous_state TEXT, + new_state TEXT, + metadata TEXT, + undone_by TEXT + )`, + `CREATE TABLE IF NOT EXISTS deal_proposals ( + id TEXT PRIMARY KEY, + tournament_id TEXT NOT NULL, + deal_type TEXT NOT NULL, + payouts TEXT NOT NULL DEFAULT '[]', + total_amount INTEGER NOT NULL DEFAULT 0, + is_partial INTEGER NOT NULL DEFAULT 0, + remaining_pool INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'proposed', + created_at INTEGER NOT NULL DEFAULT 0 + )`, + // Seed venue settings + `INSERT OR IGNORE INTO venue_settings (id, venue_name, currency_code, currency_symbol, rounding_denomination) + VALUES (1, 'Test Venue', 'DKK', 'kr', 100)`, + // Seed buyin config + `INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) + VALUES (1, 'Standard', 50000, 10000, 5000)`, + // Seed payout structure + `INSERT OR IGNORE INTO payout_structures (id, name) VALUES (1, 'Standard')`, + } + + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("exec schema stmt: %v\nStmt: %s", err, stmt) + } + } + + return db +} + +func setupChopEngine(t *testing.T, db *sql.DB) (*ChopEngine, *Engine) { + t.Helper() + trail := audit.NewTrail(db, nil) + buyinSvc := template.NewBuyinService(db) + fin := NewEngine(db, trail, nil, nil, buyinSvc) + chop := NewChopEngine(db, fin, trail, nil) + return chop, fin +} + +func setupChopScenario(t *testing.T, db *sql.DB, numPlayers int) string { + t.Helper() + tournamentID := "test-chop-tournament" + + // Create tournament + _, err := db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, is_pko) + VALUES (?, 'Test Chop', 1, 1, 'running', 0)`, + tournamentID, + ) + if err != nil { + t.Fatalf("create tournament: %v", err) + } + + // Create players and buy-ins + for i := 0; i < numPlayers; i++ { + playerID := "player-" + intStr(i+1) + playerName := "Player " + intStr(i+1) + chips := int64((i + 1) * 5000) + + _, err := db.Exec( + `INSERT INTO players (id, name) VALUES (?, ?)`, + playerID, playerName, + ) + if err != nil { + t.Fatalf("create player: %v", err) + } + + _, err = db.Exec( + `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) + VALUES (?, ?, 'active', ?)`, + tournamentID, playerID, chips, + ) + if err != nil { + t.Fatalf("create tournament_player: %v", err) + } + + // Create buy-in transaction + _, err = db.Exec( + `INSERT INTO transactions (id, tournament_id, player_id, type, amount, chips, operator_id, created_at) + VALUES (?, ?, ?, 'buyin', 50000, ?, 'op1', 0)`, + "tx-buyin-"+playerID, tournamentID, playerID, chips, + ) + if err != nil { + t.Fatalf("create transaction: %v", err) + } + } + + return tournamentID +} + +func intStr(n int) string { + if n == 0 { + return "0" + } + digits := []byte{} + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + +func TestChipChop(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupChopTestDB(t) + chop, _ := setupChopEngine(t, db) + tournamentID := setupChopScenario(t, db, 3) + + ctx := context.Background() + proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeChipChop, DealParams{}) + if err != nil { + t.Fatalf("propose chip chop: %v", err) + } + + if proposal.DealType != DealTypeChipChop { + t.Errorf("expected deal type %s, got %s", DealTypeChipChop, proposal.DealType) + } + + // Verify payouts are proportional to chips + // Chips: 5000, 10000, 15000 = total 30000 + // Pool: 3 * 50000 = 150000 (buyins) - 0 rake = 150000 + totalPool := proposal.TotalAmount + var sum int64 + for _, p := range proposal.Payouts { + sum += p.Amount + if p.Amount <= 0 { + t.Errorf("all payouts should be positive, got %d for %s", p.Amount, p.PlayerID) + } + } + if sum != totalPool { + t.Errorf("payout sum %d != total pool %d", sum, totalPool) + } + + // Player 3 (15000 chips / 30000 total = 50%) should get 50% of pool + // Player 3 is last in sorted order (highest chips = first in sorted list) + // loadActivePlayers sorts by chips DESC, so player 3 is first + if proposal.Payouts[0].Amount < proposal.Payouts[2].Amount { + t.Errorf("player with most chips should get most: %d vs %d", + proposal.Payouts[0].Amount, proposal.Payouts[2].Amount) + } +} + +func TestEvenChop(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupChopTestDB(t) + chop, _ := setupChopEngine(t, db) + tournamentID := setupChopScenario(t, db, 3) + + ctx := context.Background() + proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{}) + if err != nil { + t.Fatalf("propose even chop: %v", err) + } + + totalPool := proposal.TotalAmount + expectedPerPlayer := totalPool / 3 + + var sum int64 + for _, p := range proposal.Payouts { + diff := p.Amount - expectedPerPlayer + if diff < 0 { + diff = -diff + } + // Allow 1 cent difference for remainder distribution + if diff > 1 { + t.Errorf("even chop payout %d differs from expected %d by more than 1 cent", + p.Amount, expectedPerPlayer) + } + sum += p.Amount + } + if sum != totalPool { + t.Errorf("payout sum %d != total pool %d", sum, totalPool) + } +} + +func TestCustomSplit(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupChopTestDB(t) + chop, _ := setupChopEngine(t, db) + tournamentID := setupChopScenario(t, db, 3) + + // First calculate the pool + ctx := context.Background() + + // Get pool amount by doing an even chop first + evenProposal, _ := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{}) + totalPool := evenProposal.TotalAmount + + // Cancel the even proposal + _ = chop.CancelDeal(ctx, tournamentID, evenProposal.ID) + + // Propose custom split + params := DealParams{ + CustomAmounts: map[string]int64{ + "player-1": totalPool / 4, + "player-2": totalPool / 4, + "player-3": totalPool / 2, + }, + } + + proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeCustom, params) + if err != nil { + t.Fatalf("propose custom: %v", err) + } + + var sum int64 + for _, p := range proposal.Payouts { + sum += p.Amount + } + if sum != totalPool { + t.Errorf("custom payout sum %d != pool %d", sum, totalPool) + } +} + +func TestPartialChop(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupChopTestDB(t) + chop, _ := setupChopEngine(t, db) + tournamentID := setupChopScenario(t, db, 3) + + ctx := context.Background() + + // Get total pool + evenProposal, _ := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{}) + totalPool := evenProposal.TotalAmount + _ = chop.CancelDeal(ctx, tournamentID, evenProposal.ID) + + // Partial chop: split 60% of pool, keep 40% in play + partialAmount := totalPool * 60 / 100 + + params := DealParams{ + PartialPool: partialAmount, + RemainingPool: totalPool - partialAmount, + } + + proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypePartialChop, params) + if err != nil { + t.Fatalf("propose partial chop: %v", err) + } + + if !proposal.IsPartial { + t.Error("expected partial deal") + } + if proposal.RemainingPool != totalPool-partialAmount { + t.Errorf("remaining pool %d != expected %d", proposal.RemainingPool, totalPool-partialAmount) + } + + var sum int64 + for _, p := range proposal.Payouts { + sum += p.Amount + } + if sum != partialAmount { + t.Errorf("partial payout sum %d != partial pool %d", sum, partialAmount) + } +} + +func TestDealSetsFinishingPositions(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupChopTestDB(t) + chop, _ := setupChopEngine(t, db) + tournamentID := setupChopScenario(t, db, 3) + + ctx := context.Background() + proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{}) + if err != nil { + t.Fatalf("propose: %v", err) + } + + // Confirm the deal + if err := chop.ConfirmDeal(ctx, tournamentID, proposal.ID); err != nil { + t.Fatalf("confirm: %v", err) + } + + // Check finishing positions are set + rows, err := db.Query( + `SELECT player_id, status, finishing_position FROM tournament_players + WHERE tournament_id = ? ORDER BY finishing_position`, + tournamentID, + ) + if err != nil { + t.Fatalf("query: %v", err) + } + defer rows.Close() + + posCount := 0 + for rows.Next() { + var playerID, status string + var pos sql.NullInt64 + if err := rows.Scan(&playerID, &status, &pos); err != nil { + t.Fatalf("scan: %v", err) + } + if status != "deal" { + t.Errorf("player %s status should be 'deal', got '%s'", playerID, status) + } + if pos.Valid { + posCount++ + } + } + if posCount != 3 { + t.Errorf("expected 3 finishing positions, got %d", posCount) + } +} + +func TestFullChopEndsTournament(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupChopTestDB(t) + chop, _ := setupChopEngine(t, db) + tournamentID := setupChopScenario(t, db, 3) + + ctx := context.Background() + proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{}) + if err != nil { + t.Fatalf("propose: %v", err) + } + + if err := chop.ConfirmDeal(ctx, tournamentID, proposal.ID); err != nil { + t.Fatalf("confirm: %v", err) + } + + // Check tournament status is completed + var status string + if err := db.QueryRow("SELECT status FROM tournaments WHERE id = ?", tournamentID).Scan(&status); err != nil { + t.Fatalf("query status: %v", err) + } + if status != "completed" { + t.Errorf("expected status 'completed', got '%s'", status) + } +} diff --git a/internal/financial/icm_test.go b/internal/financial/icm_test.go new file mode 100644 index 0000000..e744702 --- /dev/null +++ b/internal/financial/icm_test.go @@ -0,0 +1,386 @@ +package financial + +import ( + "math" + "os" + "testing" + "time" +) + +func TestCalculateICMExact_TwoPlayers(t *testing.T) { + // Two players with known expected ICM values + // Player 1: 7000 chips, Player 2: 3000 chips + // Prize pool: 1st = 700, 2nd = 300 (cents) + stacks := []int64{7000, 3000} + payouts := []int64{70000, 30000} // 700.00 and 300.00 + + result, err := CalculateICMExact(stacks, payouts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 2 { + t.Fatalf("expected 2 results, got %d", len(result)) + } + + // Player 1 (70% chips) should get more than average but less than proportional + // ICM gives chip leader less equity than pure chip proportion + // Expected: Player 1 ~ 61600-62000, Player 2 ~ 38000-38400 + // (ICM redistributes compared to pure chip-chop) + totalPool := int64(100000) // 1000.00 + sum := result[0] + result[1] + if sum != totalPool { + t.Errorf("sum of ICM values %d != total pool %d", sum, totalPool) + } + + // Player 1 should get significantly more + if result[0] <= result[1] { + t.Errorf("player 1 (more chips) should have higher ICM: got %d vs %d", result[0], result[1]) + } + + // ICM gives chip leader less than pure chip proportion + chipChopP1 := totalPool * 7000 / 10000 // 70000 + if result[0] >= chipChopP1 { + t.Errorf("ICM should give chip leader less than chip chop: got %d, chip chop would be %d", result[0], chipChopP1) + } +} + +func TestCalculateICMExact_ThreePlayers(t *testing.T) { + // Three players with known stacks + stacks := []int64{5000, 3000, 2000} + payouts := []int64{50000, 30000, 20000} // 500, 300, 200 + + result, err := CalculateICMExact(stacks, payouts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 3 { + t.Fatalf("expected 3 results, got %d", len(result)) + } + + // Verify sum equals prize pool + totalPool := int64(100000) + sum := result[0] + result[1] + result[2] + if sum != totalPool { + t.Errorf("sum of ICM values %d != total pool %d", sum, totalPool) + } + + // Player ordering should match chip ordering + if result[0] <= result[1] || result[1] <= result[2] { + t.Errorf("ICM values should follow chip order: got %d, %d, %d", result[0], result[1], result[2]) + } +} + +func TestCalculateICMExact_FivePlayers(t *testing.T) { + // Five players + stacks := []int64{10000, 8000, 6000, 4000, 2000} + payouts := []int64{50000, 30000, 20000, 10000, 5000} + + result, err := CalculateICMExact(stacks, payouts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 5 { + t.Fatalf("expected 5 results, got %d", len(result)) + } + + // Verify sum equals prize pool + totalPool := int64(115000) + sum := int64(0) + for _, v := range result { + sum += v + } + if sum != totalPool { + t.Errorf("sum of ICM values %d != total pool %d", sum, totalPool) + } + + // Values should be in descending order + for i := 1; i < len(result); i++ { + if result[i] > result[i-1] { + t.Errorf("ICM value at position %d (%d) > position %d (%d)", i, result[i], i-1, result[i-1]) + } + } +} + +func TestCalculateICMExact_EqualStacks(t *testing.T) { + // All players with equal stacks should get approximately equal ICM values + stacks := []int64{5000, 5000, 5000} + payouts := []int64{60000, 30000, 10000} + + result, err := CalculateICMExact(stacks, payouts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + totalPool := int64(100000) + expected := totalPool / 3 + + // With equal stacks, all players should get the same ICM value + for i, v := range result { + diff := v - expected + if diff < 0 { + diff = -diff + } + // Allow 1 cent tolerance for rounding + if diff > 1 { + t.Errorf("player %d ICM value %d differs from expected %d by %d", i, v, expected, diff) + } + } + + // Sum must equal pool + sum := int64(0) + for _, v := range result { + sum += v + } + if sum != totalPool { + t.Errorf("sum %d != pool %d", sum, totalPool) + } +} + +func TestCalculateICMExact_DominantStack(t *testing.T) { + // One player with 99% of chips + stacks := []int64{99000, 500, 500} + payouts := []int64{60000, 30000, 10000} + + result, err := CalculateICMExact(stacks, payouts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + totalPool := int64(100000) + sum := int64(0) + for _, v := range result { + sum += v + } + if sum != totalPool { + t.Errorf("sum %d != pool %d", sum, totalPool) + } + + // Dominant stack should get close to 1st place prize but less than total + if result[0] < payouts[0]-5000 { + t.Errorf("dominant player ICM %d should be close to 1st prize %d", result[0], payouts[0]) + } + + // Small stacks should get at least some equity (ICM floor) + if result[1] <= 0 || result[2] <= 0 { + t.Errorf("small stack players should have positive ICM: %d, %d", result[1], result[2]) + } +} + +func TestCalculateICMMonteCarlo_Basic(t *testing.T) { + // 15 players - tests Monte Carlo path + stacks := make([]int64, 15) + totalChips := int64(0) + for i := range stacks { + stacks[i] = int64((i + 1) * 1000) + totalChips += stacks[i] + } + + payouts := make([]int64, 5) // Only top 5 paid + totalPool := int64(150000) + payouts[0] = 60000 + payouts[1] = 40000 + payouts[2] = 25000 + payouts[3] = 15000 + payouts[4] = 10000 + + result, err := CalculateICMMonteCarlo(stacks, payouts, 100_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 15 { + t.Fatalf("expected 15 results, got %d", len(result)) + } + + // Verify sum equals prize pool + sum := int64(0) + for _, v := range result { + sum += v + } + if sum != totalPool { + t.Errorf("sum %d != pool %d", sum, totalPool) + } + + // Player with most chips (index 14) should have highest equity + maxIdx := 0 + for i := 1; i < len(result); i++ { + if result[i] > result[maxIdx] { + maxIdx = i + } + } + if maxIdx != 14 { + t.Errorf("expected player 14 (most chips) to have highest ICM, got player %d", maxIdx) + } + + // Monte Carlo should be within ~1% of expected values + // Chip leader should get significantly more + if result[14] < result[0] { + t.Errorf("chip leader ICM %d should be more than short stack %d", result[14], result[0]) + } +} + +func TestCalculateICM_DispatcherExact(t *testing.T) { + // <=10 players should use exact + stacks := []int64{5000, 3000, 2000} + payouts := []int64{50000, 30000, 20000} + + result, err := CalculateICM(stacks, payouts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Same result as exact + exact, _ := CalculateICMExact(stacks, payouts) + for i := range result { + if result[i] != exact[i] { + t.Errorf("dispatcher result[%d]=%d != exact[%d]=%d", i, result[i], i, exact[i]) + } + } +} + +func TestCalculateICM_DispatcherMonteCarlo(t *testing.T) { + // 11 players should use Monte Carlo + stacks := make([]int64, 11) + for i := range stacks { + stacks[i] = 1000 + } + payouts := []int64{50000, 30000, 20000} + + result, err := CalculateICM(stacks, payouts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 11 { + t.Fatalf("expected 11 results, got %d", len(result)) + } + + // Sum equals pool + sum := int64(0) + for _, v := range result { + sum += v + } + totalPool := int64(100000) + if sum != totalPool { + t.Errorf("sum %d != pool %d", sum, totalPool) + } +} + +func TestCalculateICM_ValidationErrors(t *testing.T) { + // Empty stacks + _, err := CalculateICM(nil, []int64{100}) + if err == nil { + t.Error("expected error for nil stacks") + } + + // Empty payouts + _, err = CalculateICM([]int64{100}, nil) + if err == nil { + t.Error("expected error for nil payouts") + } + + // Zero stack + _, err = CalculateICM([]int64{0}, []int64{100}) + if err == nil { + t.Error("expected error for zero stack") + } + + // Negative payout + _, err = CalculateICM([]int64{100}, []int64{-50}) + if err == nil { + t.Error("expected error for negative payout") + } +} + +func TestCalculateICMExact_Performance(t *testing.T) { + if os.Getenv("CI") == "1" { + t.Skip("skipping performance test in CI") + } + + // ICM for 10 players should complete in < 1 second + stacks := make([]int64, 10) + for i := range stacks { + stacks[i] = int64((i + 1) * 1000) + } + payouts := make([]int64, 5) + payouts[0] = 50000 + payouts[1] = 30000 + payouts[2] = 15000 + payouts[3] = 5000 + payouts[4] = 2500 + + start := time.Now() + result, err := CalculateICMExact(stacks, payouts) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 10 { + t.Fatalf("expected 10 results, got %d", len(result)) + } + + if elapsed > time.Second { + t.Errorf("ICM for 10 players took %v, expected < 1s", elapsed) + } + t.Logf("ICM for 10 players: %v", elapsed) +} + +func TestCalculateICMMonteCarlo_Performance(t *testing.T) { + if os.Getenv("CI") == "1" { + t.Skip("skipping performance test in CI") + } + + // Monte Carlo ICM for 20 players should complete in < 2 seconds + stacks := make([]int64, 20) + for i := range stacks { + stacks[i] = int64((i + 1) * 500) + } + payouts := make([]int64, 5) + payouts[0] = 50000 + payouts[1] = 30000 + payouts[2] = 15000 + payouts[3] = 5000 + payouts[4] = 2500 + + start := time.Now() + result, err := CalculateICMMonteCarlo(stacks, payouts, 100_000) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 20 { + t.Fatalf("expected 20 results, got %d", len(result)) + } + + if elapsed > 2*time.Second { + t.Errorf("Monte Carlo ICM for 20 players took %v, expected < 2s", elapsed) + } + t.Logf("Monte Carlo ICM for 20 players: %v", elapsed) +} + +func TestCalculateICMMonteCarlo_Convergence(t *testing.T) { + // With equal stacks and enough iterations, Monte Carlo should converge + // to roughly equal values for all players + stacks := []int64{5000, 5000, 5000, 5000, 5000} + payouts := []int64{50000, 30000, 20000} + + result, err := CalculateICMMonteCarlo(stacks, payouts, 100_000) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := float64(100000) / 5.0 + for i, v := range result { + deviation := math.Abs(float64(v)-expected) / expected * 100 + // With equal stacks, deviation should be < 1% + if deviation > 2.0 { + t.Errorf("player %d ICM value %d deviates %.1f%% from expected %.0f", + i, v, deviation, expected) + } + } +} diff --git a/internal/tournament/integration_test.go b/internal/tournament/integration_test.go new file mode 100644 index 0000000..cac3a8b --- /dev/null +++ b/internal/tournament/integration_test.go @@ -0,0 +1,673 @@ +package tournament + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + _ "github.com/tursodatabase/go-libsql" + + "github.com/felt-app/felt/internal/audit" + "github.com/felt-app/felt/internal/clock" + "github.com/felt-app/felt/internal/financial" + "github.com/felt-app/felt/internal/template" +) + +// setupIntegrationDB creates a file-based test DB that supports concurrent +// access from background goroutines (e.g., clock ticker). In-memory DBs with +// libsql don't reliably support cross-goroutine access. +func setupIntegrationDB(t *testing.T) *sql.DB { + t.Helper() + + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "integration.db") + db, err := sql.Open("libsql", "file:"+dbPath) + if err != nil { + t.Fatalf("open test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + // Enable WAL mode for concurrent read/write access from ticker goroutine. + // PRAGMA journal_mode returns a row, so use QueryRow instead of Exec. + var journalMode string + if err := db.QueryRow("PRAGMA journal_mode=WAL").Scan(&journalMode); err != nil { + t.Fatalf("enable WAL: %v", err) + } + // Allow busy waiting for up to 5 seconds to avoid "database is locked". + var busyTimeout int + if err := db.QueryRow("PRAGMA busy_timeout=5000").Scan(&busyTimeout); err != nil { + t.Fatalf("set busy timeout: %v", err) + } + + // Apply the same schema as setupTestDB + stmts := []string{ + `CREATE TABLE IF NOT EXISTS venue_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + venue_name TEXT NOT NULL DEFAULT '', + currency_code TEXT NOT NULL DEFAULT 'DKK', + currency_symbol TEXT NOT NULL DEFAULT 'kr', + rounding_denomination INTEGER NOT NULL DEFAULT 5000, + receipt_mode TEXT NOT NULL DEFAULT 'digital', + timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen', + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS chip_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS blind_structures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + game_type_default TEXT NOT NULL DEFAULT 'nlhe', + notes TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS blind_levels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + structure_id INTEGER NOT NULL, + position INTEGER NOT NULL, + level_type TEXT NOT NULL DEFAULT 'round', + game_type TEXT NOT NULL DEFAULT 'nlhe', + small_blind INTEGER NOT NULL DEFAULT 0, + big_blind INTEGER NOT NULL DEFAULT 0, + ante INTEGER NOT NULL DEFAULT 0, + bb_ante INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 0, + chip_up_denomination_value INTEGER, + notes TEXT, + UNIQUE(structure_id, position) + )`, + `CREATE TABLE IF NOT EXISTS payout_structures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS payout_brackets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + structure_id INTEGER NOT NULL, + min_entries INTEGER NOT NULL, + max_entries INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS payout_tiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bracket_id INTEGER NOT NULL, + position INTEGER NOT NULL, + percentage_basis_points INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS buyin_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + buyin_amount INTEGER NOT NULL DEFAULT 0, + starting_chips INTEGER NOT NULL DEFAULT 0, + rake_total INTEGER NOT NULL DEFAULT 0, + bounty_amount INTEGER NOT NULL DEFAULT 0, + bounty_chip INTEGER NOT NULL DEFAULT 0, + rebuy_allowed INTEGER NOT NULL DEFAULT 0, + rebuy_cost INTEGER NOT NULL DEFAULT 0, + rebuy_chips INTEGER NOT NULL DEFAULT 0, + rebuy_rake INTEGER NOT NULL DEFAULT 0, + rebuy_limit INTEGER NOT NULL DEFAULT 0, + rebuy_level_cutoff INTEGER, + rebuy_time_cutoff_seconds INTEGER, + rebuy_chip_threshold INTEGER, + addon_allowed INTEGER NOT NULL DEFAULT 0, + addon_cost INTEGER NOT NULL DEFAULT 0, + addon_chips INTEGER NOT NULL DEFAULT 0, + addon_rake INTEGER NOT NULL DEFAULT 0, + addon_level_start INTEGER, + addon_level_end INTEGER, + reentry_allowed INTEGER NOT NULL DEFAULT 0, + reentry_limit INTEGER NOT NULL DEFAULT 0, + late_reg_level_cutoff INTEGER, + late_reg_time_cutoff_seconds INTEGER, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS rake_splits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + buyin_config_id INTEGER NOT NULL, + category TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + UNIQUE(buyin_config_id, category) + )`, + `CREATE TABLE IF NOT EXISTS tournament_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + chip_set_id INTEGER NOT NULL, + blind_structure_id INTEGER NOT NULL, + payout_structure_id INTEGER NOT NULL, + buyin_config_id INTEGER NOT NULL, + points_formula_id INTEGER, + min_players INTEGER NOT NULL DEFAULT 2, + max_players INTEGER, + early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0, + early_signup_cutoff TEXT, + punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0, + is_pko INTEGER NOT NULL DEFAULT 0, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tournaments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + template_id INTEGER, + chip_set_id INTEGER NOT NULL DEFAULT 1, + blind_structure_id INTEGER NOT NULL DEFAULT 1, + payout_structure_id INTEGER NOT NULL DEFAULT 1, + buyin_config_id INTEGER NOT NULL, + points_formula_id INTEGER, + status TEXT NOT NULL DEFAULT 'created', + min_players INTEGER NOT NULL DEFAULT 2, + max_players INTEGER, + early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0, + early_signup_cutoff TEXT, + punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0, + is_pko INTEGER NOT NULL DEFAULT 0, + current_level INTEGER NOT NULL DEFAULT 0, + clock_state TEXT NOT NULL DEFAULT 'stopped', + clock_remaining_ns INTEGER NOT NULL DEFAULT 0, + total_elapsed_ns INTEGER NOT NULL DEFAULT 0, + hand_for_hand INTEGER NOT NULL DEFAULT 0, + started_at INTEGER, + ended_at INTEGER, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + nickname TEXT, + email TEXT, + phone TEXT, + photo_url TEXT, + notes TEXT, + custom_fields TEXT, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tournament_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'registered', + seat_table_id INTEGER, + seat_position INTEGER, + buy_in_at INTEGER, + bust_out_at INTEGER, + bust_out_order INTEGER, + finishing_position INTEGER, + current_chips INTEGER NOT NULL DEFAULT 0, + rebuys INTEGER NOT NULL DEFAULT 0, + addons INTEGER NOT NULL DEFAULT 0, + reentries INTEGER NOT NULL DEFAULT 0, + bounty_value INTEGER NOT NULL DEFAULT 0, + bounties_collected INTEGER NOT NULL DEFAULT 0, + prize_amount INTEGER NOT NULL DEFAULT 0, + points_awarded INTEGER NOT NULL DEFAULT 0, + early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0, + punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0, + hitman_player_id TEXT, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(tournament_id, player_id) + )`, + `CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + type TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + chips INTEGER NOT NULL DEFAULT 0, + operator_id TEXT NOT NULL DEFAULT '', + receipt_data TEXT, + undone INTEGER NOT NULL DEFAULT 0, + undone_by TEXT, + metadata TEXT, + created_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tables ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + name TEXT NOT NULL, + seat_count INTEGER NOT NULL DEFAULT 9, + dealer_button_position INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + hand_completed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS audit_entries ( + id TEXT PRIMARY KEY, + tournament_id TEXT, + timestamp INTEGER NOT NULL, + operator_id TEXT NOT NULL DEFAULT 'system', + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + previous_state TEXT, + new_state TEXT, + metadata TEXT, + undone_by TEXT + )`, + `CREATE TABLE IF NOT EXISTS operators ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'floor', + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS chip_denominations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chip_set_id INTEGER NOT NULL, + value INTEGER NOT NULL, + color_hex TEXT NOT NULL DEFAULT '#FFFFFF', + label TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 + )`, + // Seed data + `INSERT OR IGNORE INTO venue_settings (id, venue_name, currency_code, currency_symbol, rounding_denomination) + VALUES (1, 'Test Venue', 'DKK', 'kr', 100)`, + `INSERT OR IGNORE INTO chip_sets (id, name) VALUES (1, 'Standard')`, + `INSERT OR IGNORE INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) + VALUES (1, 25, '#FFFFFF', '25', 1)`, + `INSERT OR IGNORE INTO blind_structures (id, name) VALUES (1, 'Standard')`, + `INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) + VALUES (1, 1, 'round', 'nlhe', 100, 200, 0, 0, 600, '')`, + `INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) + VALUES (1, 2, 'round', 'nlhe', 200, 400, 0, 0, 600, '')`, + `INSERT OR IGNORE INTO payout_structures (id, name) VALUES (1, 'Standard')`, + `INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) + VALUES (1, 'Standard', 50000, 10000, 5000)`, + `INSERT OR IGNORE INTO tournament_templates (id, name, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, min_players, is_pko) + VALUES (1, 'Friday Night Turbo', 1, 1, 1, 1, 3, 0)`, + } + + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("exec schema stmt: %v\nStmt: %s", err, stmt) + } + } + + return db +} + +// TestTournamentLifecycleIntegration tests the full tournament lifecycle: +// create from template, add players, start, bust players, check auto-close. +func TestTournamentLifecycleIntegration(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupIntegrationDB(t) + trail := audit.NewTrail(db, nil) + registry := clock.NewRegistry(nil) + templates := template.NewTournamentTemplateService(db) + buyinSvc := template.NewBuyinService(db) + fin := financial.NewEngine(db, trail, nil, nil, buyinSvc) + chop := financial.NewChopEngine(db, fin, trail, nil) + + svc := NewService(db, registry, fin, nil, nil, nil, nil, templates, trail, nil) + multi := NewMultiManager(svc) + + ctx := context.Background() + + // 1. Create tournament from template + tournament, err := svc.CreateFromTemplate(ctx, 1, TournamentOverrides{ + Name: "Integration Test Tourney", + }) + if err != nil { + t.Fatalf("create tournament: %v", err) + } + if tournament.Status != StatusCreated { + t.Fatalf("expected status 'created', got '%s'", tournament.Status) + } + + // 2. Add a table + _, err = db.Exec( + `INSERT INTO tables (tournament_id, name, seat_count, is_active) VALUES (?, 'Table 1', 9, 1)`, + tournament.ID, + ) + if err != nil { + t.Fatalf("create table: %v", err) + } + + // 3. Register 5 players + players := []struct{ id, name string }{ + {"p1", "Alice"}, {"p2", "Bob"}, {"p3", "Charlie"}, + {"p4", "Diana"}, {"p5", "Eve"}, + } + for _, p := range players { + _, _ = db.Exec(`INSERT OR IGNORE INTO players (id, name) VALUES (?, ?)`, p.id, p.name) + _, _ = db.Exec( + `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) + VALUES (?, ?, 'active', 10000)`, + tournament.ID, p.id, + ) + // Create buy-in transaction + _, _ = db.Exec( + `INSERT INTO transactions (id, tournament_id, player_id, type, amount, chips, operator_id, created_at) + VALUES (?, ?, ?, 'buyin', 50000, 10000, 'op1', 0)`, + "tx-"+p.id, tournament.ID, p.id, + ) + } + + // 4. Verify player summary + detail, err := svc.GetTournament(ctx, tournament.ID) + if err != nil { + t.Fatalf("get tournament: %v", err) + } + if detail.Players.Active != 5 { + t.Errorf("expected 5 active players, got %d", detail.Players.Active) + } + + // 5. Start tournament + err = svc.StartTournament(ctx, tournament.ID) + if err != nil { + t.Fatalf("start tournament: %v", err) + } + + // Stop the clock ticker -- in-memory test DBs don't support the background + // goroutine's DB access pattern, and the lifecycle test doesn't need real-time + // clock ticking. + registry.StopTicker(tournament.ID) + + // Verify status changed + detail, _ = svc.GetTournament(ctx, tournament.ID) + if detail.Tournament.Status != StatusRunning { + t.Errorf("expected status 'running', got '%s'", detail.Tournament.Status) + } + + // 6. Bust 3 players (leaving 2 active) + for _, pid := range []string{"p5", "p4", "p3"} { + _, _ = db.Exec( + `UPDATE tournament_players SET status = 'busted', current_chips = 0, + bust_out_at = unixepoch() + WHERE tournament_id = ? AND player_id = ?`, + tournament.ID, pid, + ) + } + + // 7. Prize pool should reflect buy-ins + if fin != nil { + pool, err := fin.CalculatePrizePool(ctx, tournament.ID) + if err != nil { + t.Fatalf("calculate prize pool: %v", err) + } + // 5 buyins * 50000 = 250000 + if pool.TotalBuyInAmount != 250000 { + t.Errorf("expected total buyin 250000, got %d", pool.TotalBuyInAmount) + } + } + + // 8. Check auto-close with 2 remaining (should NOT auto-close) + err = svc.CheckAutoClose(ctx, tournament.ID) + if err != nil { + t.Fatalf("check auto close (2 remaining): %v", err) + } + detail, _ = svc.GetTournament(ctx, tournament.ID) + if detail.Tournament.Status != StatusRunning { + t.Errorf("should still be running with 2 players, got '%s'", detail.Tournament.Status) + } + + // 9. Bust another player (leaving 1 active) + _, _ = db.Exec( + `UPDATE tournament_players SET status = 'busted', current_chips = 0, + bust_out_at = unixepoch() + WHERE tournament_id = ? AND player_id = 'p2'`, + tournament.ID, + ) + + // 10. Check auto-close with 1 remaining (SHOULD auto-close) + err = svc.CheckAutoClose(ctx, tournament.ID) + if err != nil { + t.Fatalf("check auto close (1 remaining): %v", err) + } + detail, _ = svc.GetTournament(ctx, tournament.ID) + if detail.Tournament.Status != StatusCompleted { + t.Errorf("should be completed with 1 player, got '%s'", detail.Tournament.Status) + } + + // 11. Verify tournament appears in completed (not active) list + active, _ := multi.ListActiveTournaments(ctx) + for _, a := range active { + if a.ID == tournament.ID { + t.Error("completed tournament should not appear in active list") + } + } + + // 12. Audit trail should have entries + var auditCount int + db.QueryRow("SELECT COUNT(*) FROM audit_entries WHERE tournament_id = ?", tournament.ID).Scan(&auditCount) + if auditCount == 0 { + t.Error("expected audit entries for tournament") + } + + // Suppress unused variable warnings + _ = chop +} + +// TestDealIntegration tests the deal workflow from proposal to confirmation. +func TestDealIntegration(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + + // Add deal_proposals table + _, _ = db.Exec(`CREATE TABLE IF NOT EXISTS deal_proposals ( + id TEXT PRIMARY KEY, + tournament_id TEXT NOT NULL, + deal_type TEXT NOT NULL, + payouts TEXT NOT NULL DEFAULT '[]', + total_amount INTEGER NOT NULL DEFAULT 0, + is_partial INTEGER NOT NULL DEFAULT 0, + remaining_pool INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'proposed', + created_at INTEGER NOT NULL DEFAULT 0 + )`) + + trail := audit.NewTrail(db, nil) + buyinSvc := template.NewBuyinService(db) + fin := financial.NewEngine(db, trail, nil, nil, buyinSvc) + chop := financial.NewChopEngine(db, fin, trail, nil) + + ctx := context.Background() + + // Create tournament with 3 active players + tournamentID := "deal-test" + _, _ = db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, min_players) + VALUES (?, 'Deal Test', 1, 1, 'running', 2)`, + tournamentID, + ) + + for i, name := range []string{"Alice", "Bob", "Charlie"} { + pid := "dp" + intToStr(i+1) + _, _ = db.Exec(`INSERT OR IGNORE INTO players (id, name) VALUES (?, ?)`, pid, name) + _, _ = db.Exec( + `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) + VALUES (?, ?, 'active', ?)`, + tournamentID, pid, (i+1)*5000, + ) + _, _ = db.Exec( + `INSERT INTO transactions (id, tournament_id, player_id, type, amount, chips, operator_id, created_at) + VALUES (?, ?, ?, 'buyin', 50000, ?, 'op1', 0)`, + "tx-deal-"+pid, tournamentID, pid, (i+1)*5000, + ) + } + + // Propose an even chop + proposal, err := chop.ProposeDeal(ctx, tournamentID, "even_chop", financial.DealParams{}) + if err != nil { + t.Fatalf("propose deal: %v", err) + } + + if proposal.Status != "proposed" { + t.Errorf("expected status 'proposed', got '%s'", proposal.Status) + } + if len(proposal.Payouts) != 3 { + t.Errorf("expected 3 payouts, got %d", len(proposal.Payouts)) + } + + // Verify sum equals pool + var sum int64 + for _, p := range proposal.Payouts { + sum += p.Amount + } + if sum != proposal.TotalAmount { + t.Errorf("payout sum %d != total amount %d", sum, proposal.TotalAmount) + } + + // Confirm the deal + err = chop.ConfirmDeal(ctx, tournamentID, proposal.ID) + if err != nil { + t.Fatalf("confirm deal: %v", err) + } + + // Verify tournament is completed (full chop) + var status string + db.QueryRow("SELECT status FROM tournaments WHERE id = ?", tournamentID).Scan(&status) + if status != "completed" { + t.Errorf("expected 'completed', got '%s'", status) + } + + // Verify players have 'deal' status + var dealCount int + db.QueryRow( + "SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND status = 'deal'", + tournamentID, + ).Scan(&dealCount) + if dealCount != 3 { + t.Errorf("expected 3 players with 'deal' status, got %d", dealCount) + } + + // Verify chop transactions were created + var chopTxCount int + db.QueryRow( + "SELECT COUNT(*) FROM transactions WHERE tournament_id = ? AND type = 'chop'", + tournamentID, + ).Scan(&chopTxCount) + if chopTxCount != 3 { + t.Errorf("expected 3 chop transactions, got %d", chopTxCount) + } + + // Verify total payouts equal prize pool + var totalPaid int64 + db.QueryRow( + "SELECT COALESCE(SUM(amount), 0) FROM transactions WHERE tournament_id = ? AND type = 'chop'", + tournamentID, + ).Scan(&totalPaid) + if totalPaid != proposal.TotalAmount { + t.Errorf("total paid %d != proposed total %d", totalPaid, proposal.TotalAmount) + } +} + +// TestCancelTournament tests cancellation flow. +func TestCancelTournament(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + + // Create and start a tournament + tournamentID := "cancel-test" + _, _ = db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, min_players) + VALUES (?, 'Cancel Test', 1, 1, 'running', 2)`, + tournamentID, + ) + + // Add some transactions + _, _ = db.Exec( + `INSERT INTO transactions (id, tournament_id, player_id, type, amount, chips, operator_id, created_at) + VALUES ('cancel-tx-1', ?, 'p1', 'buyin', 50000, 10000, 'op1', 0)`, + tournamentID, + ) + + // Cancel + err := svc.CancelTournament(ctx, tournamentID) + if err != nil { + t.Fatalf("cancel: %v", err) + } + + // Verify status + var status string + db.QueryRow("SELECT status FROM tournaments WHERE id = ?", tournamentID).Scan(&status) + if status != StatusCancelled { + t.Errorf("expected 'cancelled', got '%s'", status) + } + + // Verify transactions marked with cancelled metadata + var meta sql.NullString + db.QueryRow( + "SELECT metadata FROM transactions WHERE id = 'cancel-tx-1'", + ).Scan(&meta) + if !meta.Valid { + t.Error("transaction metadata should be set after cancel") + } +} + +// TestPauseResume tests pause and resume flow. +func TestPauseResume(t *testing.T) { + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + + tournamentID := "pause-test" + _, _ = db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, min_players) + VALUES (?, 'Pause Test', 1, 1, 'running', 2)`, + tournamentID, + ) + + // Pause + err := svc.PauseTournament(ctx, tournamentID) + if err != nil { + t.Fatalf("pause: %v", err) + } + + var status string + db.QueryRow("SELECT status FROM tournaments WHERE id = ?", tournamentID).Scan(&status) + if status != StatusPaused { + t.Errorf("expected 'paused', got '%s'", status) + } + + // Resume + err = svc.ResumeTournament(ctx, tournamentID) + if err != nil { + t.Fatalf("resume: %v", err) + } + + db.QueryRow("SELECT status FROM tournaments WHERE id = ?", tournamentID).Scan(&status) + if status != StatusRunning { + t.Errorf("expected 'running', got '%s'", status) + } +} diff --git a/internal/tournament/tournament.go b/internal/tournament/tournament.go index a170e48..9ef87bb 100644 --- a/internal/tournament/tournament.go +++ b/internal/tournament/tournament.go @@ -165,6 +165,7 @@ func (s *Service) CreateFromTemplate(ctx context.Context, templateID int64, over // Load the expanded template expanded, err := s.templates.GetTemplateExpanded(ctx, templateID) if err != nil { + log.Printf("tournament: get template expanded: %v", err) return nil, ErrTemplateNotFound } diff --git a/internal/tournament/tournament_test.go b/internal/tournament/tournament_test.go new file mode 100644 index 0000000..66d1c81 --- /dev/null +++ b/internal/tournament/tournament_test.go @@ -0,0 +1,581 @@ +package tournament + +import ( + "context" + "database/sql" + "os" + "testing" + + _ "github.com/tursodatabase/go-libsql" + + "github.com/felt-app/felt/internal/audit" + "github.com/felt-app/felt/internal/clock" + "github.com/felt-app/felt/internal/template" +) + +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + + if os.Getenv("CGO_ENABLED") == "0" { + t.Skip("requires CGO for libsql") + } + + dbName := "file:" + t.Name() + "?mode=memory" + db, err := sql.Open("libsql", dbName) + if err != nil { + t.Fatalf("open test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + stmts := []string{ + `CREATE TABLE IF NOT EXISTS venue_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + venue_name TEXT NOT NULL DEFAULT '', + currency_code TEXT NOT NULL DEFAULT 'DKK', + currency_symbol TEXT NOT NULL DEFAULT 'kr', + rounding_denomination INTEGER NOT NULL DEFAULT 5000, + receipt_mode TEXT NOT NULL DEFAULT 'digital', + timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen', + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS chip_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS blind_structures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + game_type_default TEXT NOT NULL DEFAULT 'nlhe', + notes TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS blind_levels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + structure_id INTEGER NOT NULL, + position INTEGER NOT NULL, + level_type TEXT NOT NULL DEFAULT 'round', + game_type TEXT NOT NULL DEFAULT 'nlhe', + small_blind INTEGER NOT NULL DEFAULT 0, + big_blind INTEGER NOT NULL DEFAULT 0, + ante INTEGER NOT NULL DEFAULT 0, + bb_ante INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 0, + chip_up_denomination_value INTEGER, + notes TEXT, + UNIQUE(structure_id, position) + )`, + `CREATE TABLE IF NOT EXISTS payout_structures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS payout_brackets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + structure_id INTEGER NOT NULL, + min_entries INTEGER NOT NULL, + max_entries INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS payout_tiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bracket_id INTEGER NOT NULL, + position INTEGER NOT NULL, + percentage_basis_points INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS buyin_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + buyin_amount INTEGER NOT NULL DEFAULT 0, + starting_chips INTEGER NOT NULL DEFAULT 0, + rake_total INTEGER NOT NULL DEFAULT 0, + bounty_amount INTEGER NOT NULL DEFAULT 0, + bounty_chip INTEGER NOT NULL DEFAULT 0, + rebuy_allowed INTEGER NOT NULL DEFAULT 0, + rebuy_cost INTEGER NOT NULL DEFAULT 0, + rebuy_chips INTEGER NOT NULL DEFAULT 0, + rebuy_rake INTEGER NOT NULL DEFAULT 0, + rebuy_limit INTEGER NOT NULL DEFAULT 0, + rebuy_level_cutoff INTEGER, + rebuy_time_cutoff_seconds INTEGER, + rebuy_chip_threshold INTEGER, + addon_allowed INTEGER NOT NULL DEFAULT 0, + addon_cost INTEGER NOT NULL DEFAULT 0, + addon_chips INTEGER NOT NULL DEFAULT 0, + addon_rake INTEGER NOT NULL DEFAULT 0, + addon_level_start INTEGER, + addon_level_end INTEGER, + reentry_allowed INTEGER NOT NULL DEFAULT 0, + reentry_limit INTEGER NOT NULL DEFAULT 0, + late_reg_level_cutoff INTEGER, + late_reg_time_cutoff_seconds INTEGER, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS rake_splits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + buyin_config_id INTEGER NOT NULL, + category TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + UNIQUE(buyin_config_id, category) + )`, + `CREATE TABLE IF NOT EXISTS tournament_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + chip_set_id INTEGER NOT NULL, + blind_structure_id INTEGER NOT NULL, + payout_structure_id INTEGER NOT NULL, + buyin_config_id INTEGER NOT NULL, + points_formula_id INTEGER, + min_players INTEGER NOT NULL DEFAULT 2, + max_players INTEGER, + early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0, + early_signup_cutoff TEXT, + punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0, + is_pko INTEGER NOT NULL DEFAULT 0, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tournaments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + template_id INTEGER, + chip_set_id INTEGER NOT NULL DEFAULT 1, + blind_structure_id INTEGER NOT NULL DEFAULT 1, + payout_structure_id INTEGER NOT NULL DEFAULT 1, + buyin_config_id INTEGER NOT NULL, + points_formula_id INTEGER, + status TEXT NOT NULL DEFAULT 'created', + min_players INTEGER NOT NULL DEFAULT 2, + max_players INTEGER, + early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0, + early_signup_cutoff TEXT, + punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0, + is_pko INTEGER NOT NULL DEFAULT 0, + current_level INTEGER NOT NULL DEFAULT 0, + clock_state TEXT NOT NULL DEFAULT 'stopped', + clock_remaining_ns INTEGER NOT NULL DEFAULT 0, + total_elapsed_ns INTEGER NOT NULL DEFAULT 0, + hand_for_hand INTEGER NOT NULL DEFAULT 0, + started_at INTEGER, + ended_at INTEGER, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS players ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + nickname TEXT, + email TEXT, + phone TEXT, + photo_url TEXT, + notes TEXT, + custom_fields TEXT, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tournament_players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'registered', + seat_table_id INTEGER, + seat_position INTEGER, + buy_in_at INTEGER, + bust_out_at INTEGER, + bust_out_order INTEGER, + finishing_position INTEGER, + current_chips INTEGER NOT NULL DEFAULT 0, + rebuys INTEGER NOT NULL DEFAULT 0, + addons INTEGER NOT NULL DEFAULT 0, + reentries INTEGER NOT NULL DEFAULT 0, + bounty_value INTEGER NOT NULL DEFAULT 0, + bounties_collected INTEGER NOT NULL DEFAULT 0, + prize_amount INTEGER NOT NULL DEFAULT 0, + points_awarded INTEGER NOT NULL DEFAULT 0, + early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0, + punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0, + hitman_player_id TEXT, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(tournament_id, player_id) + )`, + `CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + tournament_id TEXT NOT NULL, + player_id TEXT NOT NULL, + type TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + chips INTEGER NOT NULL DEFAULT 0, + operator_id TEXT NOT NULL DEFAULT '', + receipt_data TEXT, + undone INTEGER NOT NULL DEFAULT 0, + undone_by TEXT, + metadata TEXT, + created_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS tables ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tournament_id TEXT NOT NULL, + name TEXT NOT NULL, + seat_count INTEGER NOT NULL DEFAULT 9, + dealer_button_position INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + hand_completed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS audit_entries ( + id TEXT PRIMARY KEY, + tournament_id TEXT, + timestamp INTEGER NOT NULL, + operator_id TEXT NOT NULL DEFAULT 'system', + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + previous_state TEXT, + new_state TEXT, + metadata TEXT, + undone_by TEXT + )`, + `CREATE TABLE IF NOT EXISTS operators ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'floor', + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS chip_denominations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chip_set_id INTEGER NOT NULL, + value INTEGER NOT NULL, + color_hex TEXT NOT NULL DEFAULT '#FFFFFF', + label TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 + )`, + // Seed data + `INSERT OR IGNORE INTO venue_settings (id, venue_name, currency_code, currency_symbol, rounding_denomination) + VALUES (1, 'Test Venue', 'DKK', 'kr', 100)`, + `INSERT OR IGNORE INTO chip_sets (id, name) VALUES (1, 'Standard')`, + `INSERT OR IGNORE INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) + VALUES (1, 25, '#FFFFFF', '25', 1)`, + `INSERT OR IGNORE INTO blind_structures (id, name) VALUES (1, 'Standard')`, + `INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) + VALUES (1, 1, 'round', 'nlhe', 100, 200, 0, 0, 600, '')`, + `INSERT OR IGNORE INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, notes) + VALUES (1, 2, 'round', 'nlhe', 200, 400, 0, 0, 600, '')`, + `INSERT OR IGNORE INTO payout_structures (id, name) VALUES (1, 'Standard')`, + `INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total) + VALUES (1, 'Standard', 50000, 10000, 5000)`, + `INSERT OR IGNORE INTO tournament_templates (id, name, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, min_players, is_pko) + VALUES (1, 'Friday Night Turbo', 1, 1, 1, 1, 3, 0)`, + } + + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("exec schema stmt: %v\nStmt: %s", err, stmt) + } + } + + return db +} + +func newTestService(t *testing.T, db *sql.DB) *Service { + t.Helper() + trail := audit.NewTrail(db, nil) + registry := clock.NewRegistry(nil) + templates := template.NewTournamentTemplateService(db) + + return NewService(db, registry, nil, nil, nil, nil, nil, templates, trail, nil) +} + +func TestCreateFromTemplate(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + tournament, err := svc.CreateFromTemplate(ctx, 1, TournamentOverrides{ + Name: "My Test Tournament", + }) + if err != nil { + t.Fatalf("create from template: %v", err) + } + + if tournament.Name != "My Test Tournament" { + t.Errorf("expected name 'My Test Tournament', got '%s'", tournament.Name) + } + if tournament.Status != StatusCreated { + t.Errorf("expected status 'created', got '%s'", tournament.Status) + } + if tournament.TemplateID == nil || *tournament.TemplateID != 1 { + t.Error("template_id should be 1") + } + if tournament.ChipSetID != 1 { + t.Errorf("chip_set_id should be 1, got %d", tournament.ChipSetID) + } + if tournament.BlindStructureID != 1 { + t.Errorf("blind_structure_id should be 1, got %d", tournament.BlindStructureID) + } + if tournament.MinPlayers != 3 { + t.Errorf("min_players should be 3 (from template), got %d", tournament.MinPlayers) + } +} + +func TestCreateFromTemplate_WithOverrides(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + minPlayers := 5 + tournament, err := svc.CreateFromTemplate(ctx, 1, TournamentOverrides{ + Name: "Custom Name", + MinPlayers: &minPlayers, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if tournament.Name != "Custom Name" { + t.Errorf("expected name override, got '%s'", tournament.Name) + } + if tournament.MinPlayers != 5 { + t.Errorf("expected min_players override 5, got %d", tournament.MinPlayers) + } +} + +func TestCreateFromTemplate_NotFound(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + _, err := svc.CreateFromTemplate(ctx, 9999, TournamentOverrides{}) + if err != ErrTemplateNotFound { + t.Errorf("expected ErrTemplateNotFound, got %v", err) + } +} + +func TestStartTournament_RequiresMinPlayers(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + tournament, err := svc.CreateFromTemplate(ctx, 1, TournamentOverrides{ + Name: "Min Players Test", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Add a table + _, _ = db.Exec( + `INSERT INTO tables (tournament_id, name, seat_count, is_active) VALUES (?, 'Table 1', 9, 1)`, + tournament.ID, + ) + + // Try to start without enough players (min is 3 from template) + err = svc.StartTournament(ctx, tournament.ID) + if err != ErrMinPlayersNotMet { + t.Errorf("expected ErrMinPlayersNotMet, got %v", err) + } +} + +func TestAutoCloseWhenOnePlayerRemains(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + + // Create tournament directly in DB as running + tournamentID := "auto-close-test" + _, _ = db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, min_players) + VALUES (?, 'Auto Close Test', 1, 1, 'running', 2)`, + tournamentID, + ) + + // Add 2 players + _, _ = db.Exec(`INSERT INTO players (id, name) VALUES ('p1', 'Alice')`) + _, _ = db.Exec(`INSERT INTO players (id, name) VALUES ('p2', 'Bob')`) + _, _ = db.Exec( + `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) VALUES (?, 'p1', 'active', 10000)`, + tournamentID, + ) + _, _ = db.Exec( + `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) VALUES (?, 'p2', 'busted', 0)`, + tournamentID, + ) + + // Check auto close -- 1 active player should trigger end + err := svc.CheckAutoClose(ctx, tournamentID) + if err != nil { + t.Fatalf("check auto close: %v", err) + } + + // Verify tournament is completed + var status string + if err := db.QueryRow("SELECT status FROM tournaments WHERE id = ?", tournamentID).Scan(&status); err != nil { + t.Fatalf("query: %v", err) + } + if status != StatusCompleted { + t.Errorf("expected status 'completed', got '%s'", status) + } +} + +func TestMultipleTournamentsRunIndependently(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + + // Create two tournaments from the same template + t1, err := svc.CreateFromTemplate(ctx, 1, TournamentOverrides{Name: "Tournament A"}) + if err != nil { + t.Fatalf("create t1: %v", err) + } + t2, err := svc.CreateFromTemplate(ctx, 1, TournamentOverrides{Name: "Tournament B"}) + if err != nil { + t.Fatalf("create t2: %v", err) + } + + // Verify they have different IDs + if t1.ID == t2.ID { + t.Error("tournaments should have different IDs") + } + + // Verify they can be loaded independently + detail1, err := svc.GetTournament(ctx, t1.ID) + if err != nil { + t.Fatalf("get t1: %v", err) + } + detail2, err := svc.GetTournament(ctx, t2.ID) + if err != nil { + t.Fatalf("get t2: %v", err) + } + + if detail1.Tournament.Name != "Tournament A" { + t.Errorf("t1 name mismatch: %s", detail1.Tournament.Name) + } + if detail2.Tournament.Name != "Tournament B" { + t.Errorf("t2 name mismatch: %s", detail2.Tournament.Name) + } + + // Add players to only t1 + _, _ = db.Exec(`INSERT OR IGNORE INTO players (id, name) VALUES ('p-multi-1', 'Player 1')`) + _, _ = db.Exec( + `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) VALUES (?, 'p-multi-1', 'active', 10000)`, + t1.ID, + ) + + // t1 should have 1 player, t2 should have 0 + d1, _ := svc.GetTournament(ctx, t1.ID) + d2, _ := svc.GetTournament(ctx, t2.ID) + + if d1.Players.Total != 1 { + t.Errorf("t1 should have 1 player, got %d", d1.Players.Total) + } + if d2.Players.Total != 0 { + t.Errorf("t2 should have 0 players, got %d", d2.Players.Total) + } +} + +func TestTournamentStateAggregation(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + + tournament, err := svc.CreateFromTemplate(ctx, 1, TournamentOverrides{Name: "State Test"}) + if err != nil { + t.Fatalf("create: %v", err) + } + + // Get state + state, err := svc.GetTournamentState(ctx, tournament.ID) + if err != nil { + t.Fatalf("get state: %v", err) + } + + // Verify all components are present + if state.Tournament.ID != tournament.ID { + t.Error("state should include tournament data") + } + if state.Players.Total != 0 { + t.Errorf("expected 0 players, got %d", state.Players.Total) + } +} + +func TestMultiManagerListActive(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + multi := NewMultiManager(svc) + + ctx := context.Background() + + // Create tournaments with different statuses + _, _ = db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, min_players) + VALUES ('t-active-1', 'Active 1', 1, 1, 'running', 2)`, + ) + _, _ = db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, min_players) + VALUES ('t-active-2', 'Active 2', 1, 1, 'paused', 2)`, + ) + _, _ = db.Exec( + `INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, min_players) + VALUES ('t-done', 'Done', 1, 1, 'completed', 2)`, + ) + + active, err := multi.ListActiveTournaments(ctx) + if err != nil { + t.Fatalf("list active: %v", err) + } + + // Should include running and paused, not completed + if len(active) != 2 { + t.Errorf("expected 2 active tournaments, got %d", len(active)) + } +} + +func TestCreateManual(t *testing.T) { + db := setupTestDB(t) + svc := newTestService(t, db) + + ctx := context.Background() + config := TournamentConfig{ + Name: "Manual Tournament", + ChipSetID: 1, + BlindStructureID: 1, + PayoutStructureID: 1, + BuyinConfigID: 1, + MinPlayers: 4, + IsPKO: true, + } + + tournament, err := svc.CreateManual(ctx, config) + if err != nil { + t.Fatalf("create manual: %v", err) + } + + if tournament.Name != "Manual Tournament" { + t.Errorf("name mismatch: %s", tournament.Name) + } + if tournament.MinPlayers != 4 { + t.Errorf("min_players: %d", tournament.MinPlayers) + } + if !tournament.IsPKO { + t.Error("should be PKO") + } + if tournament.TemplateID != nil { + t.Error("manual tournament should have nil template_id") + } +}