felt/internal/financial/chop_test.go
Mikkel Georgsen 295844983a test(01-09): add ICM, chop/deal, tournament lifecycle, and integration tests
- 12 ICM tests: exact/Monte Carlo, validation, performance, convergence
- 6 chop/deal tests: chip chop, even chop, custom, partial, positions, tournament end
- 9 tournament unit tests: template creation, overrides, start validation, auto-close, multi-tournament, state aggregation
- 4 integration tests: full lifecycle, deal workflow, cancel, pause/resume
- Fix integration test DB concurrency with file-based DB + WAL mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:10:52 +01:00

530 lines
15 KiB
Go

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