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>
This commit is contained in:
parent
75ccb6f735
commit
295844983a
5 changed files with 2171 additions and 0 deletions
530
internal/financial/chop_test.go
Normal file
530
internal/financial/chop_test.go
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
package financial
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/tursodatabase/go-libsql"
|
||||
|
||||
"github.com/felt-app/felt/internal/audit"
|
||||
"github.com/felt-app/felt/internal/template"
|
||||
)
|
||||
|
||||
// setupChopTestDB creates a test database with all required tables.
|
||||
func setupChopTestDB(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 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 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 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 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 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 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
|
||||
)`,
|
||||
// Seed venue settings
|
||||
`INSERT OR IGNORE INTO venue_settings (id, venue_name, currency_code, currency_symbol, rounding_denomination)
|
||||
VALUES (1, 'Test Venue', 'DKK', 'kr', 100)`,
|
||||
// Seed buyin config
|
||||
`INSERT OR IGNORE INTO buyin_configs (id, name, buyin_amount, starting_chips, rake_total)
|
||||
VALUES (1, 'Standard', 50000, 10000, 5000)`,
|
||||
// Seed payout structure
|
||||
`INSERT OR IGNORE INTO payout_structures (id, name) VALUES (1, 'Standard')`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
t.Fatalf("exec schema stmt: %v\nStmt: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func setupChopEngine(t *testing.T, db *sql.DB) (*ChopEngine, *Engine) {
|
||||
t.Helper()
|
||||
trail := audit.NewTrail(db, nil)
|
||||
buyinSvc := template.NewBuyinService(db)
|
||||
fin := NewEngine(db, trail, nil, nil, buyinSvc)
|
||||
chop := NewChopEngine(db, fin, trail, nil)
|
||||
return chop, fin
|
||||
}
|
||||
|
||||
func setupChopScenario(t *testing.T, db *sql.DB, numPlayers int) string {
|
||||
t.Helper()
|
||||
tournamentID := "test-chop-tournament"
|
||||
|
||||
// Create tournament
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO tournaments (id, name, buyin_config_id, payout_structure_id, status, is_pko)
|
||||
VALUES (?, 'Test Chop', 1, 1, 'running', 0)`,
|
||||
tournamentID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create tournament: %v", err)
|
||||
}
|
||||
|
||||
// Create players and buy-ins
|
||||
for i := 0; i < numPlayers; i++ {
|
||||
playerID := "player-" + intStr(i+1)
|
||||
playerName := "Player " + intStr(i+1)
|
||||
chips := int64((i + 1) * 5000)
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO players (id, name) VALUES (?, ?)`,
|
||||
playerID, playerName,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create player: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO tournament_players (tournament_id, player_id, status, current_chips)
|
||||
VALUES (?, ?, 'active', ?)`,
|
||||
tournamentID, playerID, chips,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create tournament_player: %v", err)
|
||||
}
|
||||
|
||||
// Create buy-in transaction
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO transactions (id, tournament_id, player_id, type, amount, chips, operator_id, created_at)
|
||||
VALUES (?, ?, ?, 'buyin', 50000, ?, 'op1', 0)`,
|
||||
"tx-buyin-"+playerID, tournamentID, playerID, chips,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tournamentID
|
||||
}
|
||||
|
||||
func intStr(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := []byte{}
|
||||
for n > 0 {
|
||||
digits = append([]byte{byte('0' + n%10)}, digits...)
|
||||
n /= 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
func TestChipChop(t *testing.T) {
|
||||
if os.Getenv("CGO_ENABLED") == "0" {
|
||||
t.Skip("requires CGO for libsql")
|
||||
}
|
||||
|
||||
db := setupChopTestDB(t)
|
||||
chop, _ := setupChopEngine(t, db)
|
||||
tournamentID := setupChopScenario(t, db, 3)
|
||||
|
||||
ctx := context.Background()
|
||||
proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeChipChop, DealParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("propose chip chop: %v", err)
|
||||
}
|
||||
|
||||
if proposal.DealType != DealTypeChipChop {
|
||||
t.Errorf("expected deal type %s, got %s", DealTypeChipChop, proposal.DealType)
|
||||
}
|
||||
|
||||
// Verify payouts are proportional to chips
|
||||
// Chips: 5000, 10000, 15000 = total 30000
|
||||
// Pool: 3 * 50000 = 150000 (buyins) - 0 rake = 150000
|
||||
totalPool := proposal.TotalAmount
|
||||
var sum int64
|
||||
for _, p := range proposal.Payouts {
|
||||
sum += p.Amount
|
||||
if p.Amount <= 0 {
|
||||
t.Errorf("all payouts should be positive, got %d for %s", p.Amount, p.PlayerID)
|
||||
}
|
||||
}
|
||||
if sum != totalPool {
|
||||
t.Errorf("payout sum %d != total pool %d", sum, totalPool)
|
||||
}
|
||||
|
||||
// Player 3 (15000 chips / 30000 total = 50%) should get 50% of pool
|
||||
// Player 3 is last in sorted order (highest chips = first in sorted list)
|
||||
// loadActivePlayers sorts by chips DESC, so player 3 is first
|
||||
if proposal.Payouts[0].Amount < proposal.Payouts[2].Amount {
|
||||
t.Errorf("player with most chips should get most: %d vs %d",
|
||||
proposal.Payouts[0].Amount, proposal.Payouts[2].Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvenChop(t *testing.T) {
|
||||
if os.Getenv("CGO_ENABLED") == "0" {
|
||||
t.Skip("requires CGO for libsql")
|
||||
}
|
||||
|
||||
db := setupChopTestDB(t)
|
||||
chop, _ := setupChopEngine(t, db)
|
||||
tournamentID := setupChopScenario(t, db, 3)
|
||||
|
||||
ctx := context.Background()
|
||||
proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("propose even chop: %v", err)
|
||||
}
|
||||
|
||||
totalPool := proposal.TotalAmount
|
||||
expectedPerPlayer := totalPool / 3
|
||||
|
||||
var sum int64
|
||||
for _, p := range proposal.Payouts {
|
||||
diff := p.Amount - expectedPerPlayer
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// Allow 1 cent difference for remainder distribution
|
||||
if diff > 1 {
|
||||
t.Errorf("even chop payout %d differs from expected %d by more than 1 cent",
|
||||
p.Amount, expectedPerPlayer)
|
||||
}
|
||||
sum += p.Amount
|
||||
}
|
||||
if sum != totalPool {
|
||||
t.Errorf("payout sum %d != total pool %d", sum, totalPool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomSplit(t *testing.T) {
|
||||
if os.Getenv("CGO_ENABLED") == "0" {
|
||||
t.Skip("requires CGO for libsql")
|
||||
}
|
||||
|
||||
db := setupChopTestDB(t)
|
||||
chop, _ := setupChopEngine(t, db)
|
||||
tournamentID := setupChopScenario(t, db, 3)
|
||||
|
||||
// First calculate the pool
|
||||
ctx := context.Background()
|
||||
|
||||
// Get pool amount by doing an even chop first
|
||||
evenProposal, _ := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{})
|
||||
totalPool := evenProposal.TotalAmount
|
||||
|
||||
// Cancel the even proposal
|
||||
_ = chop.CancelDeal(ctx, tournamentID, evenProposal.ID)
|
||||
|
||||
// Propose custom split
|
||||
params := DealParams{
|
||||
CustomAmounts: map[string]int64{
|
||||
"player-1": totalPool / 4,
|
||||
"player-2": totalPool / 4,
|
||||
"player-3": totalPool / 2,
|
||||
},
|
||||
}
|
||||
|
||||
proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeCustom, params)
|
||||
if err != nil {
|
||||
t.Fatalf("propose custom: %v", err)
|
||||
}
|
||||
|
||||
var sum int64
|
||||
for _, p := range proposal.Payouts {
|
||||
sum += p.Amount
|
||||
}
|
||||
if sum != totalPool {
|
||||
t.Errorf("custom payout sum %d != pool %d", sum, totalPool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialChop(t *testing.T) {
|
||||
if os.Getenv("CGO_ENABLED") == "0" {
|
||||
t.Skip("requires CGO for libsql")
|
||||
}
|
||||
|
||||
db := setupChopTestDB(t)
|
||||
chop, _ := setupChopEngine(t, db)
|
||||
tournamentID := setupChopScenario(t, db, 3)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get total pool
|
||||
evenProposal, _ := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{})
|
||||
totalPool := evenProposal.TotalAmount
|
||||
_ = chop.CancelDeal(ctx, tournamentID, evenProposal.ID)
|
||||
|
||||
// Partial chop: split 60% of pool, keep 40% in play
|
||||
partialAmount := totalPool * 60 / 100
|
||||
|
||||
params := DealParams{
|
||||
PartialPool: partialAmount,
|
||||
RemainingPool: totalPool - partialAmount,
|
||||
}
|
||||
|
||||
proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypePartialChop, params)
|
||||
if err != nil {
|
||||
t.Fatalf("propose partial chop: %v", err)
|
||||
}
|
||||
|
||||
if !proposal.IsPartial {
|
||||
t.Error("expected partial deal")
|
||||
}
|
||||
if proposal.RemainingPool != totalPool-partialAmount {
|
||||
t.Errorf("remaining pool %d != expected %d", proposal.RemainingPool, totalPool-partialAmount)
|
||||
}
|
||||
|
||||
var sum int64
|
||||
for _, p := range proposal.Payouts {
|
||||
sum += p.Amount
|
||||
}
|
||||
if sum != partialAmount {
|
||||
t.Errorf("partial payout sum %d != partial pool %d", sum, partialAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDealSetsFinishingPositions(t *testing.T) {
|
||||
if os.Getenv("CGO_ENABLED") == "0" {
|
||||
t.Skip("requires CGO for libsql")
|
||||
}
|
||||
|
||||
db := setupChopTestDB(t)
|
||||
chop, _ := setupChopEngine(t, db)
|
||||
tournamentID := setupChopScenario(t, db, 3)
|
||||
|
||||
ctx := context.Background()
|
||||
proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("propose: %v", err)
|
||||
}
|
||||
|
||||
// Confirm the deal
|
||||
if err := chop.ConfirmDeal(ctx, tournamentID, proposal.ID); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
|
||||
// Check finishing positions are set
|
||||
rows, err := db.Query(
|
||||
`SELECT player_id, status, finishing_position FROM tournament_players
|
||||
WHERE tournament_id = ? ORDER BY finishing_position`,
|
||||
tournamentID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
posCount := 0
|
||||
for rows.Next() {
|
||||
var playerID, status string
|
||||
var pos sql.NullInt64
|
||||
if err := rows.Scan(&playerID, &status, &pos); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if status != "deal" {
|
||||
t.Errorf("player %s status should be 'deal', got '%s'", playerID, status)
|
||||
}
|
||||
if pos.Valid {
|
||||
posCount++
|
||||
}
|
||||
}
|
||||
if posCount != 3 {
|
||||
t.Errorf("expected 3 finishing positions, got %d", posCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChopEndsTournament(t *testing.T) {
|
||||
if os.Getenv("CGO_ENABLED") == "0" {
|
||||
t.Skip("requires CGO for libsql")
|
||||
}
|
||||
|
||||
db := setupChopTestDB(t)
|
||||
chop, _ := setupChopEngine(t, db)
|
||||
tournamentID := setupChopScenario(t, db, 3)
|
||||
|
||||
ctx := context.Background()
|
||||
proposal, err := chop.ProposeDeal(ctx, tournamentID, DealTypeEvenChop, DealParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("propose: %v", err)
|
||||
}
|
||||
|
||||
if err := chop.ConfirmDeal(ctx, tournamentID, proposal.ID); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
|
||||
// Check tournament status is completed
|
||||
var status string
|
||||
if err := db.QueryRow("SELECT status FROM tournaments WHERE id = ?", tournamentID).Scan(&status); err != nil {
|
||||
t.Fatalf("query status: %v", err)
|
||||
}
|
||||
if status != "completed" {
|
||||
t.Errorf("expected status 'completed', got '%s'", status)
|
||||
}
|
||||
}
|
||||
386
internal/financial/icm_test.go
Normal file
386
internal/financial/icm_test.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
package financial
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalculateICMExact_TwoPlayers(t *testing.T) {
|
||||
// Two players with known expected ICM values
|
||||
// Player 1: 7000 chips, Player 2: 3000 chips
|
||||
// Prize pool: 1st = 700, 2nd = 300 (cents)
|
||||
stacks := []int64{7000, 3000}
|
||||
payouts := []int64{70000, 30000} // 700.00 and 300.00
|
||||
|
||||
result, err := CalculateICMExact(stacks, payouts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(result))
|
||||
}
|
||||
|
||||
// Player 1 (70% chips) should get more than average but less than proportional
|
||||
// ICM gives chip leader less equity than pure chip proportion
|
||||
// Expected: Player 1 ~ 61600-62000, Player 2 ~ 38000-38400
|
||||
// (ICM redistributes compared to pure chip-chop)
|
||||
totalPool := int64(100000) // 1000.00
|
||||
sum := result[0] + result[1]
|
||||
if sum != totalPool {
|
||||
t.Errorf("sum of ICM values %d != total pool %d", sum, totalPool)
|
||||
}
|
||||
|
||||
// Player 1 should get significantly more
|
||||
if result[0] <= result[1] {
|
||||
t.Errorf("player 1 (more chips) should have higher ICM: got %d vs %d", result[0], result[1])
|
||||
}
|
||||
|
||||
// ICM gives chip leader less than pure chip proportion
|
||||
chipChopP1 := totalPool * 7000 / 10000 // 70000
|
||||
if result[0] >= chipChopP1 {
|
||||
t.Errorf("ICM should give chip leader less than chip chop: got %d, chip chop would be %d", result[0], chipChopP1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICMExact_ThreePlayers(t *testing.T) {
|
||||
// Three players with known stacks
|
||||
stacks := []int64{5000, 3000, 2000}
|
||||
payouts := []int64{50000, 30000, 20000} // 500, 300, 200
|
||||
|
||||
result, err := CalculateICMExact(stacks, payouts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify sum equals prize pool
|
||||
totalPool := int64(100000)
|
||||
sum := result[0] + result[1] + result[2]
|
||||
if sum != totalPool {
|
||||
t.Errorf("sum of ICM values %d != total pool %d", sum, totalPool)
|
||||
}
|
||||
|
||||
// Player ordering should match chip ordering
|
||||
if result[0] <= result[1] || result[1] <= result[2] {
|
||||
t.Errorf("ICM values should follow chip order: got %d, %d, %d", result[0], result[1], result[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICMExact_FivePlayers(t *testing.T) {
|
||||
// Five players
|
||||
stacks := []int64{10000, 8000, 6000, 4000, 2000}
|
||||
payouts := []int64{50000, 30000, 20000, 10000, 5000}
|
||||
|
||||
result, err := CalculateICMExact(stacks, payouts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 5 {
|
||||
t.Fatalf("expected 5 results, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify sum equals prize pool
|
||||
totalPool := int64(115000)
|
||||
sum := int64(0)
|
||||
for _, v := range result {
|
||||
sum += v
|
||||
}
|
||||
if sum != totalPool {
|
||||
t.Errorf("sum of ICM values %d != total pool %d", sum, totalPool)
|
||||
}
|
||||
|
||||
// Values should be in descending order
|
||||
for i := 1; i < len(result); i++ {
|
||||
if result[i] > result[i-1] {
|
||||
t.Errorf("ICM value at position %d (%d) > position %d (%d)", i, result[i], i-1, result[i-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICMExact_EqualStacks(t *testing.T) {
|
||||
// All players with equal stacks should get approximately equal ICM values
|
||||
stacks := []int64{5000, 5000, 5000}
|
||||
payouts := []int64{60000, 30000, 10000}
|
||||
|
||||
result, err := CalculateICMExact(stacks, payouts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
totalPool := int64(100000)
|
||||
expected := totalPool / 3
|
||||
|
||||
// With equal stacks, all players should get the same ICM value
|
||||
for i, v := range result {
|
||||
diff := v - expected
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// Allow 1 cent tolerance for rounding
|
||||
if diff > 1 {
|
||||
t.Errorf("player %d ICM value %d differs from expected %d by %d", i, v, expected, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// Sum must equal pool
|
||||
sum := int64(0)
|
||||
for _, v := range result {
|
||||
sum += v
|
||||
}
|
||||
if sum != totalPool {
|
||||
t.Errorf("sum %d != pool %d", sum, totalPool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICMExact_DominantStack(t *testing.T) {
|
||||
// One player with 99% of chips
|
||||
stacks := []int64{99000, 500, 500}
|
||||
payouts := []int64{60000, 30000, 10000}
|
||||
|
||||
result, err := CalculateICMExact(stacks, payouts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
totalPool := int64(100000)
|
||||
sum := int64(0)
|
||||
for _, v := range result {
|
||||
sum += v
|
||||
}
|
||||
if sum != totalPool {
|
||||
t.Errorf("sum %d != pool %d", sum, totalPool)
|
||||
}
|
||||
|
||||
// Dominant stack should get close to 1st place prize but less than total
|
||||
if result[0] < payouts[0]-5000 {
|
||||
t.Errorf("dominant player ICM %d should be close to 1st prize %d", result[0], payouts[0])
|
||||
}
|
||||
|
||||
// Small stacks should get at least some equity (ICM floor)
|
||||
if result[1] <= 0 || result[2] <= 0 {
|
||||
t.Errorf("small stack players should have positive ICM: %d, %d", result[1], result[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICMMonteCarlo_Basic(t *testing.T) {
|
||||
// 15 players - tests Monte Carlo path
|
||||
stacks := make([]int64, 15)
|
||||
totalChips := int64(0)
|
||||
for i := range stacks {
|
||||
stacks[i] = int64((i + 1) * 1000)
|
||||
totalChips += stacks[i]
|
||||
}
|
||||
|
||||
payouts := make([]int64, 5) // Only top 5 paid
|
||||
totalPool := int64(150000)
|
||||
payouts[0] = 60000
|
||||
payouts[1] = 40000
|
||||
payouts[2] = 25000
|
||||
payouts[3] = 15000
|
||||
payouts[4] = 10000
|
||||
|
||||
result, err := CalculateICMMonteCarlo(stacks, payouts, 100_000)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 15 {
|
||||
t.Fatalf("expected 15 results, got %d", len(result))
|
||||
}
|
||||
|
||||
// Verify sum equals prize pool
|
||||
sum := int64(0)
|
||||
for _, v := range result {
|
||||
sum += v
|
||||
}
|
||||
if sum != totalPool {
|
||||
t.Errorf("sum %d != pool %d", sum, totalPool)
|
||||
}
|
||||
|
||||
// Player with most chips (index 14) should have highest equity
|
||||
maxIdx := 0
|
||||
for i := 1; i < len(result); i++ {
|
||||
if result[i] > result[maxIdx] {
|
||||
maxIdx = i
|
||||
}
|
||||
}
|
||||
if maxIdx != 14 {
|
||||
t.Errorf("expected player 14 (most chips) to have highest ICM, got player %d", maxIdx)
|
||||
}
|
||||
|
||||
// Monte Carlo should be within ~1% of expected values
|
||||
// Chip leader should get significantly more
|
||||
if result[14] < result[0] {
|
||||
t.Errorf("chip leader ICM %d should be more than short stack %d", result[14], result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICM_DispatcherExact(t *testing.T) {
|
||||
// <=10 players should use exact
|
||||
stacks := []int64{5000, 3000, 2000}
|
||||
payouts := []int64{50000, 30000, 20000}
|
||||
|
||||
result, err := CalculateICM(stacks, payouts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Same result as exact
|
||||
exact, _ := CalculateICMExact(stacks, payouts)
|
||||
for i := range result {
|
||||
if result[i] != exact[i] {
|
||||
t.Errorf("dispatcher result[%d]=%d != exact[%d]=%d", i, result[i], i, exact[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICM_DispatcherMonteCarlo(t *testing.T) {
|
||||
// 11 players should use Monte Carlo
|
||||
stacks := make([]int64, 11)
|
||||
for i := range stacks {
|
||||
stacks[i] = 1000
|
||||
}
|
||||
payouts := []int64{50000, 30000, 20000}
|
||||
|
||||
result, err := CalculateICM(stacks, payouts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 11 {
|
||||
t.Fatalf("expected 11 results, got %d", len(result))
|
||||
}
|
||||
|
||||
// Sum equals pool
|
||||
sum := int64(0)
|
||||
for _, v := range result {
|
||||
sum += v
|
||||
}
|
||||
totalPool := int64(100000)
|
||||
if sum != totalPool {
|
||||
t.Errorf("sum %d != pool %d", sum, totalPool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICM_ValidationErrors(t *testing.T) {
|
||||
// Empty stacks
|
||||
_, err := CalculateICM(nil, []int64{100})
|
||||
if err == nil {
|
||||
t.Error("expected error for nil stacks")
|
||||
}
|
||||
|
||||
// Empty payouts
|
||||
_, err = CalculateICM([]int64{100}, nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for nil payouts")
|
||||
}
|
||||
|
||||
// Zero stack
|
||||
_, err = CalculateICM([]int64{0}, []int64{100})
|
||||
if err == nil {
|
||||
t.Error("expected error for zero stack")
|
||||
}
|
||||
|
||||
// Negative payout
|
||||
_, err = CalculateICM([]int64{100}, []int64{-50})
|
||||
if err == nil {
|
||||
t.Error("expected error for negative payout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateICMExact_Performance(t *testing.T) {
|
||||
if os.Getenv("CI") == "1" {
|
||||
t.Skip("skipping performance test in CI")
|
||||
}
|
||||
|
||||
// ICM for 10 players should complete in < 1 second
|
||||
stacks := make([]int64, 10)
|
||||
for i := range stacks {
|
||||
stacks[i] = int64((i + 1) * 1000)
|
||||
}
|
||||
payouts := make([]int64, 5)
|
||||
payouts[0] = 50000
|
||||
payouts[1] = 30000
|
||||
payouts[2] = 15000
|
||||
payouts[3] = 5000
|
||||
payouts[4] = 2500
|
||||
|
||||
start := time.Now()
|
||||
result, err := CalculateICMExact(stacks, payouts)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 10 {
|
||||
t.Fatalf("expected 10 results, got %d", len(result))
|
||||
}
|
||||
|
||||
if elapsed > time.Second {
|
||||
t.Errorf("ICM for 10 players took %v, expected < 1s", elapsed)
|
||||
}
|
||||
t.Logf("ICM for 10 players: %v", elapsed)
|
||||
}
|
||||
|
||||
func TestCalculateICMMonteCarlo_Performance(t *testing.T) {
|
||||
if os.Getenv("CI") == "1" {
|
||||
t.Skip("skipping performance test in CI")
|
||||
}
|
||||
|
||||
// Monte Carlo ICM for 20 players should complete in < 2 seconds
|
||||
stacks := make([]int64, 20)
|
||||
for i := range stacks {
|
||||
stacks[i] = int64((i + 1) * 500)
|
||||
}
|
||||
payouts := make([]int64, 5)
|
||||
payouts[0] = 50000
|
||||
payouts[1] = 30000
|
||||
payouts[2] = 15000
|
||||
payouts[3] = 5000
|
||||
payouts[4] = 2500
|
||||
|
||||
start := time.Now()
|
||||
result, err := CalculateICMMonteCarlo(stacks, payouts, 100_000)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != 20 {
|
||||
t.Fatalf("expected 20 results, got %d", len(result))
|
||||
}
|
||||
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("Monte Carlo ICM for 20 players took %v, expected < 2s", elapsed)
|
||||
}
|
||||
t.Logf("Monte Carlo ICM for 20 players: %v", elapsed)
|
||||
}
|
||||
|
||||
func TestCalculateICMMonteCarlo_Convergence(t *testing.T) {
|
||||
// With equal stacks and enough iterations, Monte Carlo should converge
|
||||
// to roughly equal values for all players
|
||||
stacks := []int64{5000, 5000, 5000, 5000, 5000}
|
||||
payouts := []int64{50000, 30000, 20000}
|
||||
|
||||
result, err := CalculateICMMonteCarlo(stacks, payouts, 100_000)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expected := float64(100000) / 5.0
|
||||
for i, v := range result {
|
||||
deviation := math.Abs(float64(v)-expected) / expected * 100
|
||||
// With equal stacks, deviation should be < 1%
|
||||
if deviation > 2.0 {
|
||||
t.Errorf("player %d ICM value %d deviates %.1f%% from expected %.0f",
|
||||
i, v, deviation, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
673
internal/tournament/integration_test.go
Normal file
673
internal/tournament/integration_test.go
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +165,7 @@ func (s *Service) CreateFromTemplate(ctx context.Context, templateID int64, over
|
|||
// Load the expanded template
|
||||
expanded, err := s.templates.GetTemplateExpanded(ctx, templateID)
|
||||
if err != nil {
|
||||
log.Printf("tournament: get template expanded: %v", err)
|
||||
return nil, ErrTemplateNotFound
|
||||
}
|
||||
|
||||
|
|
|
|||
581
internal/tournament/tournament_test.go
Normal file
581
internal/tournament/tournament_test.go
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue