- PlayerHandler with all CRUD routes and tournament player operations - Buy-in flow: register + financial engine + auto-seat suggestion - Bust flow: hitman selection + bounty transfer + re-ranking - Undo bust with full re-ranking and rankings response - Rankings API endpoint returning derived positions - QR code endpoint returns PNG image with Cache-Control header - CSV import via multipart upload (admin only) - Player merge endpoint (admin only) - CSV export safety: formula injection neutralization (tab-prefix =,+,-,@) - Ranking tests: bust order, undo re-ranking, early undo, re-entry, deal positions, auto-close, concurrent busts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
533 lines
14 KiB
Go
533 lines
14 KiB
Go
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))
|
|
}
|
|
}
|