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