felt/internal/tournament/tournament_test.go
Mikkel Georgsen 295844983a 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 <noreply@anthropic.com>
2026-03-01 08:10:52 +01:00

581 lines
18 KiB
Go

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")
}
}