- 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>
673 lines
22 KiB
Go
673 lines
22 KiB
Go
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)
|
|
}
|
|
}
|