package player import ( "context" "database/sql" "os" "testing" "time" _ "github.com/tursodatabase/go-libsql" ) // testDB creates a temporary LibSQL database with the required schema. func testDB(t *testing.T) *sql.DB { t.Helper() tmpFile := t.TempDir() + "/test.db" db, err := sql.Open("libsql", "file:"+tmpFile) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() os.Remove(tmpFile) }) // Create minimal schema for ranking tests stmts := []string{ `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 (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) )`, `CREATE TABLE IF NOT EXISTS tournaments ( id TEXT PRIMARY KEY, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'running', is_pko INTEGER NOT NULL DEFAULT 0, max_players 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 DEFAULT 1, 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, hand_for_hand_hand_number INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) )`, `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, hitman_player_id TEXT, early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0, punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), 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 'test', receipt_data TEXT, undone INTEGER NOT NULL DEFAULT 0, undone_by TEXT, metadata TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()) )`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { t.Fatalf("create schema: %v", err) } } return db } // seedTournament creates a tournament and players for testing. func seedTournament(t *testing.T, db *sql.DB, playerCount int) (string, []string) { t.Helper() ctx := context.Background() tid := "tournament-1" _, err := db.ExecContext(ctx, `INSERT INTO tournaments (id, name, status) VALUES (?, ?, 'running')`, tid, "Test Tournament", ) if err != nil { t.Fatalf("insert tournament: %v", err) } var playerIDs []string for i := 0; i < playerCount; i++ { pid := generateUUID() name := charName(i) _, err := db.ExecContext(ctx, `INSERT INTO players (id, name) VALUES (?, ?)`, pid, name, ) if err != nil { t.Fatalf("insert player %d: %v", i, err) } _, err = db.ExecContext(ctx, `INSERT INTO tournament_players (tournament_id, player_id, status, current_chips) VALUES (?, ?, 'active', 10000)`, tid, pid, ) if err != nil { t.Fatalf("insert tournament_player %d: %v", i, err) } playerIDs = append(playerIDs, pid) } return tid, playerIDs } // bustPlayer busts a player with the given bust_out_at timestamp. func bustPlayer(t *testing.T, db *sql.DB, tid, pid string, bustOrder int, bustTime int64) { t.Helper() var totalEntries int db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tid).Scan(&totalEntries) finishingPos := totalEntries - bustOrder + 1 _, err := db.Exec( `UPDATE tournament_players SET status = 'busted', bust_out_at = ?, bust_out_order = ?, finishing_position = ?, current_chips = 0, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, bustTime, bustOrder, finishingPos, tid, pid, ) if err != nil { t.Fatalf("bust player: %v", err) } } func charName(i int) string { return string(rune('A' + i)) } // ---------- Tests ---------- func TestRankingsFromBustOrder(t *testing.T) { db := testDB(t) ctx := context.Background() engine := NewRankingEngine(db, nil) // Create 5 players, bust 3 of them tid, pids := seedTournament(t, db, 5) // Bust player 0 first (should be last place) bustPlayer(t, db, tid, pids[0], 1, 1000) // Bust player 1 second bustPlayer(t, db, tid, pids[1], 2, 2000) // Bust player 2 third bustPlayer(t, db, tid, pids[2], 3, 3000) rankings, err := engine.CalculateRankings(ctx, tid) if err != nil { t.Fatalf("calculate rankings: %v", err) } if len(rankings) != 5 { t.Fatalf("expected 5 rankings, got %d", len(rankings)) } // Active players should be position 1 for _, r := range rankings { if r.Status == "active" { if r.Position != 1 { t.Errorf("active player %s should be position 1, got %d", r.PlayerName, r.Position) } } } // Busted players: bust_out_order 3 (last busted) -> highest finishing position // With 5 players: bustOrder 1 -> pos 5, bustOrder 2 -> pos 4, bustOrder 3 -> pos 3 bustOrderToExpectedPos := map[string]int{ pids[0]: 5, // First busted = last place pids[1]: 4, pids[2]: 3, } for _, r := range rankings { if expected, ok := bustOrderToExpectedPos[r.PlayerID]; ok { if r.Position != expected { t.Errorf("player %s (bust order position): expected %d, got %d", r.PlayerName, expected, r.Position) } } } } func TestUndoBustTriggersReRanking(t *testing.T) { db := testDB(t) ctx := context.Background() engine := NewRankingEngine(db, nil) // Create 5 players, bust all 5 tid, pids := seedTournament(t, db, 5) now := time.Now().Unix() for i := 0; i < 5; i++ { bustPlayer(t, db, tid, pids[i], i+1, now+int64(i)*10) } // Undo bust for player 2 (middle bust) _, err := db.Exec( `UPDATE tournament_players SET status = 'active', bust_out_at = NULL, bust_out_order = NULL, finishing_position = NULL, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, tid, pids[2], ) if err != nil { t.Fatalf("undo bust: %v", err) } // Recalculate rankings err = engine.RecalculateAllRankings(ctx, tid) if err != nil { t.Fatalf("recalculate: %v", err) } // Verify bust_out_order has been recalculated // Remaining busted players: 0 (bust_out_at=now), 1 (now+10), 3 (now+30), 4 (now+40) // New bust orders: 1, 2, 3, 4 type bustInfo struct { PlayerID string Status string BustOutOrder sql.NullInt64 FinishingPos sql.NullInt64 } rows, err := db.Query( `SELECT player_id, status, bust_out_order, finishing_position FROM tournament_players WHERE tournament_id = ? ORDER BY player_id`, tid, ) if err != nil { t.Fatalf("query: %v", err) } defer rows.Close() busted := make(map[string]bustInfo) for rows.Next() { var bi bustInfo rows.Scan(&bi.PlayerID, &bi.Status, &bi.BustOutOrder, &bi.FinishingPos) busted[bi.PlayerID] = bi } // Player 2 should be active if busted[pids[2]].Status != "active" { t.Errorf("player 2 should be active, got %s", busted[pids[2]].Status) } // Remaining busted players should have bust_out_order recalculated // 4 busted players remain out of 5 total expectedBustOrders := map[string]int{ pids[0]: 1, // earliest bust pids[1]: 2, pids[3]: 3, pids[4]: 4, // latest bust } for pid, expectedOrder := range expectedBustOrders { bi := busted[pid] if !bi.BustOutOrder.Valid || int(bi.BustOutOrder.Int64) != expectedOrder { t.Errorf("player %s: expected bust_out_order %d, got %v", pid, expectedOrder, bi.BustOutOrder) } } } func TestUndoEarlyBustReranksCorrectly(t *testing.T) { db := testDB(t) ctx := context.Background() engine := NewRankingEngine(db, nil) // Create 6 players, bust first 4 tid, pids := seedTournament(t, db, 6) now := time.Now().Unix() bustPlayer(t, db, tid, pids[0], 1, now) bustPlayer(t, db, tid, pids[1], 2, now+100) bustPlayer(t, db, tid, pids[2], 3, now+200) bustPlayer(t, db, tid, pids[3], 4, now+300) // Undo bust of player 0 (the very first bust) _, _ = db.Exec( `UPDATE tournament_players SET status = 'active', bust_out_at = NULL, bust_out_order = NULL, finishing_position = NULL, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, tid, pids[0], ) err := engine.RecalculateAllRankings(ctx, tid) if err != nil { t.Fatalf("recalculate: %v", err) } // Now only 3 busted: pids[1], pids[2], pids[3] with bust_out_order 1, 2, 3 // 6 total players, finishing positions should be: // bust_out_order 1 (pids[1]) -> finishing_pos = 6 - 1 + 1 = 6 // bust_out_order 2 (pids[2]) -> finishing_pos = 6 - 2 + 1 = 5 // bust_out_order 3 (pids[3]) -> finishing_pos = 6 - 3 + 1 = 4 type result struct { BustOutOrder sql.NullInt64 FinishingPos sql.NullInt64 } check := func(pid string, expectedOrder, expectedPos int) { var r result db.QueryRow( `SELECT bust_out_order, finishing_position FROM tournament_players WHERE tournament_id = ? AND player_id = ?`, tid, pid, ).Scan(&r.BustOutOrder, &r.FinishingPos) if !r.BustOutOrder.Valid || int(r.BustOutOrder.Int64) != expectedOrder { t.Errorf("player %s: expected bust_out_order %d, got %v", pid, expectedOrder, r.BustOutOrder) } if !r.FinishingPos.Valid || int(r.FinishingPos.Int64) != expectedPos { t.Errorf("player %s: expected finishing_position %d, got %v", pid, expectedPos, r.FinishingPos) } } check(pids[1], 1, 6) check(pids[2], 2, 5) check(pids[3], 3, 4) } func TestReEntryDoesNotCountAsNewEntry(t *testing.T) { db := testDB(t) ctx := context.Background() engine := NewRankingEngine(db, nil) // Create 4 players, bust one, then re-enter them tid, pids := seedTournament(t, db, 4) // Bust player 0 bustPlayer(t, db, tid, pids[0], 1, 1000) // Re-enter player 0 (status back to active, bust fields cleared) _, _ = db.Exec( `UPDATE tournament_players SET status = 'active', bust_out_at = NULL, bust_out_order = NULL, finishing_position = NULL, reentries = reentries + 1, current_chips = 10000, updated_at = unixepoch() WHERE tournament_id = ? AND player_id = ?`, tid, pids[0], ) // Total players is still 4 (re-entry is same player) var total int db.QueryRow(`SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ?`, tid).Scan(&total) if total != 4 { t.Errorf("expected 4 tournament_players, got %d", total) } rankings, err := engine.CalculateRankings(ctx, tid) if err != nil { t.Fatalf("calculate: %v", err) } // All 4 should be active activeCount := 0 for _, r := range rankings { if r.Status == "active" { activeCount++ } } if activeCount != 4 { t.Errorf("expected 4 active players, got %d", activeCount) } } func TestDealPlayersGetManualPositions(t *testing.T) { db := testDB(t) ctx := context.Background() engine := NewRankingEngine(db, nil) tid, pids := seedTournament(t, db, 4) // Bust players 0 and 1 bustPlayer(t, db, tid, pids[0], 1, 1000) bustPlayer(t, db, tid, pids[1], 2, 2000) // Players 2 and 3 make a deal; manually assign finishing positions _, _ = db.Exec( `UPDATE tournament_players SET status = 'deal', finishing_position = 1 WHERE tournament_id = ? AND player_id = ?`, tid, pids[3], ) _, _ = db.Exec( `UPDATE tournament_players SET status = 'deal', finishing_position = 2 WHERE tournament_id = ? AND player_id = ?`, tid, pids[2], ) rankings, err := engine.CalculateRankings(ctx, tid) if err != nil { t.Fatalf("calculate: %v", err) } // Find deal players and verify positions for _, r := range rankings { switch r.PlayerID { case pids[3]: if r.Position != 1 { t.Errorf("deal player %s expected position 1, got %d", r.PlayerName, r.Position) } case pids[2]: if r.Position != 2 { t.Errorf("deal player %s expected position 2, got %d", r.PlayerName, r.Position) } } } } func TestAutoCloseOnOnePlayerRemaining(t *testing.T) { db := testDB(t) tid, pids := seedTournament(t, db, 3) // Bust 2 of 3 players bustPlayer(t, db, tid, pids[0], 1, 1000) bustPlayer(t, db, tid, pids[1], 2, 2000) // Check active count var activeCount int db.QueryRow( `SELECT COUNT(*) FROM tournament_players WHERE tournament_id = ? AND status = 'active'`, tid, ).Scan(&activeCount) if activeCount != 1 { t.Errorf("expected 1 active player, got %d", activeCount) } } func TestConcurrentBustsPreserveOrder(t *testing.T) { db := testDB(t) ctx := context.Background() engine := NewRankingEngine(db, nil) tid, pids := seedTournament(t, db, 5) // Bust players at same timestamp (concurrent busts) sameTime := time.Now().Unix() bustPlayer(t, db, tid, pids[0], 1, sameTime) bustPlayer(t, db, tid, pids[1], 2, sameTime) bustPlayer(t, db, tid, pids[2], 3, sameTime) // Recalculate to ensure consistency err := engine.RecalculateAllRankings(ctx, tid) if err != nil { t.Fatalf("recalculate: %v", err) } // Verify all 3 busted players have distinct bust_out_order orders := make(map[int]bool) rows, _ := db.Query( `SELECT bust_out_order FROM tournament_players WHERE tournament_id = ? AND status = 'busted' ORDER BY bust_out_order`, tid, ) defer rows.Close() for rows.Next() { var order int rows.Scan(&order) if orders[order] { t.Errorf("duplicate bust_out_order: %d", order) } orders[order] = true } if len(orders) != 3 { t.Errorf("expected 3 distinct bust orders, got %d", len(orders)) } }