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