- 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>
530 lines
15 KiB
Go
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)
|
|
}
|
|
}
|