The testDB() function used cache=shared which caused UNIQUE constraint failures when multiple tests seeded the same chip_sets row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
658 lines
20 KiB
Go
658 lines
20 KiB
Go
package financial
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
|
|
_ "github.com/tursodatabase/go-libsql"
|
|
|
|
"github.com/felt-app/felt/internal/audit"
|
|
"github.com/felt-app/felt/internal/server/middleware"
|
|
"github.com/felt-app/felt/internal/template"
|
|
)
|
|
|
|
// testDB creates an in-memory SQLite database with the required schema.
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := sql.Open("libsql", "file:"+t.Name()+"?mode=memory")
|
|
if err != nil {
|
|
t.Fatalf("open test db: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
// Create minimal schema
|
|
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 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 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,
|
|
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 audit_entries (
|
|
id TEXT PRIMARY KEY,
|
|
tournament_id TEXT,
|
|
timestamp INTEGER NOT NULL DEFAULT 0,
|
|
operator_id TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT NOT NULL DEFAULT '',
|
|
target_id TEXT NOT NULL DEFAULT '',
|
|
previous_state TEXT,
|
|
new_state TEXT,
|
|
metadata TEXT,
|
|
undone_by TEXT
|
|
)`,
|
|
// Seed required references
|
|
`INSERT INTO chip_sets (id, name) VALUES (1, 'Test Chips')`,
|
|
`INSERT INTO blind_structures (id, name) VALUES (1, 'Test Blinds')`,
|
|
`INSERT INTO payout_structures (id, name) VALUES (1, 'Test Payouts')`,
|
|
}
|
|
|
|
for _, stmt := range stmts {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
t.Fatalf("exec schema: %s: %v", stmt[:50], err)
|
|
}
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
// setupTestEngine creates an Engine with test dependencies.
|
|
func setupTestEngine(t *testing.T) (*Engine, *sql.DB) {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
trail := audit.NewTrail(db, nil)
|
|
undo := audit.NewUndoEngine(trail)
|
|
buyinSvc := template.NewBuyinService(db)
|
|
eng := NewEngine(db, trail, undo, nil, buyinSvc)
|
|
return eng, db
|
|
}
|
|
|
|
// seedBuyinConfig creates a test buy-in config and returns its ID.
|
|
func seedBuyinConfig(t *testing.T, db *sql.DB, amount, chips, rake int64) int64 {
|
|
t.Helper()
|
|
res, err := db.Exec(
|
|
`INSERT INTO buyin_configs (name, buyin_amount, starting_chips, rake_total,
|
|
rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit,
|
|
addon_allowed, addon_cost, addon_chips, addon_rake,
|
|
reentry_allowed, reentry_limit)
|
|
VALUES ('Test Buyin', ?, ?, ?, 1, ?, ?, ?, 3, 1, ?, ?, ?, 1, 2)`,
|
|
amount, chips, rake,
|
|
amount, chips, rake/2, // rebuy costs same as buyin, half rake
|
|
amount/2, chips/2, rake/4, // addon costs half, quarter rake
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed buyin config: %v", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
|
|
// Add rake splits
|
|
if rake > 0 {
|
|
houseRake := rake * 60 / 100
|
|
staffRake := rake - houseRake
|
|
db.Exec("INSERT INTO rake_splits (buyin_config_id, category, amount) VALUES (?, 'house', ?)", id, houseRake)
|
|
db.Exec("INSERT INTO rake_splits (buyin_config_id, category, amount) VALUES (?, 'staff', ?)", id, staffRake)
|
|
}
|
|
|
|
return id
|
|
}
|
|
|
|
// seedTournament creates a test tournament.
|
|
func seedTournament(t *testing.T, db *sql.DB, id string, buyinConfigID int64, isPKO bool) {
|
|
t.Helper()
|
|
pko := 0
|
|
if isPKO {
|
|
pko = 1
|
|
}
|
|
_, err := db.Exec(
|
|
`INSERT INTO tournaments (id, name, buyin_config_id, is_pko, status)
|
|
VALUES (?, 'Test Tournament', ?, ?, 'running')`,
|
|
id, buyinConfigID, pko,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed tournament: %v", err)
|
|
}
|
|
}
|
|
|
|
// seedPlayer creates a test player and registers them in a tournament.
|
|
func seedPlayer(t *testing.T, db *sql.DB, tournamentID, playerID, status string) {
|
|
t.Helper()
|
|
db.Exec("INSERT OR IGNORE INTO players (id, name) VALUES (?, ?)", playerID, "Player "+playerID)
|
|
_, err := db.Exec(
|
|
"INSERT INTO tournament_players (tournament_id, player_id, status) VALUES (?, ?, ?)",
|
|
tournamentID, playerID, status,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed player: %v", err)
|
|
}
|
|
}
|
|
|
|
func ctxWithOperator() context.Context {
|
|
ctx := context.Background()
|
|
ctx = context.WithValue(ctx, middleware.OperatorIDKey, "op-test")
|
|
ctx = context.WithValue(ctx, middleware.OperatorRoleKey, "admin")
|
|
return ctx
|
|
}
|
|
|
|
func TestProcessBuyIn(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000) // 100 EUR buyin, 150 chips, 20 rake
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "registered")
|
|
|
|
tx, err := eng.ProcessBuyIn(ctx, "t1", "p1", false)
|
|
if err != nil {
|
|
t.Fatalf("process buyin: %v", err)
|
|
}
|
|
|
|
if tx.Amount != 10000 {
|
|
t.Errorf("expected amount 10000, got %d", tx.Amount)
|
|
}
|
|
if tx.Chips != 15000 {
|
|
t.Errorf("expected chips 15000, got %d", tx.Chips)
|
|
}
|
|
if tx.Type != TxTypeBuyIn {
|
|
t.Errorf("expected type buyin, got %s", tx.Type)
|
|
}
|
|
|
|
// Verify player state was updated
|
|
var status string
|
|
var chips int64
|
|
db.QueryRow("SELECT status, current_chips FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&status, &chips)
|
|
if status != "active" {
|
|
t.Errorf("expected player status 'active', got %q", status)
|
|
}
|
|
if chips != 15000 {
|
|
t.Errorf("expected player chips 15000, got %d", chips)
|
|
}
|
|
|
|
// Verify rake transactions were created
|
|
var rakeCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM transactions WHERE tournament_id = 't1' AND type = 'rake'").Scan(&rakeCount)
|
|
if rakeCount != 2 { // house + staff
|
|
t.Errorf("expected 2 rake transactions, got %d", rakeCount)
|
|
}
|
|
}
|
|
|
|
func TestProcessBuyIn_LateRegClosed(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
// Set late reg cutoff to level 6
|
|
db.Exec("UPDATE buyin_configs SET late_reg_level_cutoff = 6 WHERE id = ?", cfgID)
|
|
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
// Simulate clock past cutoff
|
|
db.Exec("UPDATE tournaments SET clock_state = 'running', current_level = 7 WHERE id = 't1'")
|
|
seedPlayer(t, db, "t1", "p1", "registered")
|
|
|
|
_, err := eng.ProcessBuyIn(ctx, "t1", "p1", false)
|
|
if err != ErrLateRegistrationClosed {
|
|
t.Fatalf("expected ErrLateRegistrationClosed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessBuyIn_AdminOverride(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
db.Exec("UPDATE buyin_configs SET late_reg_level_cutoff = 6 WHERE id = ?", cfgID)
|
|
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
db.Exec("UPDATE tournaments SET clock_state = 'running', current_level = 7 WHERE id = 't1'")
|
|
seedPlayer(t, db, "t1", "p1", "registered")
|
|
|
|
// With override=true, should succeed
|
|
tx, err := eng.ProcessBuyIn(ctx, "t1", "p1", true)
|
|
if err != nil {
|
|
t.Fatalf("expected admin override to succeed: %v", err)
|
|
}
|
|
if tx.Amount != 10000 {
|
|
t.Errorf("expected amount 10000, got %d", tx.Amount)
|
|
}
|
|
}
|
|
|
|
func TestProcessRebuy(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "active")
|
|
|
|
tx, err := eng.ProcessRebuy(ctx, "t1", "p1")
|
|
if err != nil {
|
|
t.Fatalf("process rebuy: %v", err)
|
|
}
|
|
|
|
if tx.Type != TxTypeRebuy {
|
|
t.Errorf("expected type rebuy, got %s", tx.Type)
|
|
}
|
|
|
|
// Verify rebuy count incremented
|
|
var rebuys int
|
|
db.QueryRow("SELECT rebuys FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&rebuys)
|
|
if rebuys != 1 {
|
|
t.Errorf("expected rebuys 1, got %d", rebuys)
|
|
}
|
|
}
|
|
|
|
func TestProcessRebuy_LimitReached(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "active")
|
|
|
|
// Set rebuy count to limit
|
|
db.Exec("UPDATE tournament_players SET rebuys = 3 WHERE tournament_id = 't1' AND player_id = 'p1'")
|
|
|
|
_, err := eng.ProcessRebuy(ctx, "t1", "p1")
|
|
if err != ErrRebuyLimitReached {
|
|
t.Fatalf("expected ErrRebuyLimitReached, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessAddOn(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "active")
|
|
|
|
tx, err := eng.ProcessAddOn(ctx, "t1", "p1")
|
|
if err != nil {
|
|
t.Fatalf("process addon: %v", err)
|
|
}
|
|
|
|
if tx.Type != TxTypeAddon {
|
|
t.Errorf("expected type addon, got %s", tx.Type)
|
|
}
|
|
|
|
// Verify addon count
|
|
var addons int
|
|
db.QueryRow("SELECT addons FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&addons)
|
|
if addons != 1 {
|
|
t.Errorf("expected addons 1, got %d", addons)
|
|
}
|
|
}
|
|
|
|
func TestProcessAddOn_AlreadyUsed(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "active")
|
|
db.Exec("UPDATE tournament_players SET addons = 1 WHERE tournament_id = 't1' AND player_id = 'p1'")
|
|
|
|
_, err := eng.ProcessAddOn(ctx, "t1", "p1")
|
|
if err != ErrAddonAlreadyUsed {
|
|
t.Fatalf("expected ErrAddonAlreadyUsed, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessReEntry(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "busted") // Must be busted
|
|
|
|
tx, err := eng.ProcessReEntry(ctx, "t1", "p1")
|
|
if err != nil {
|
|
t.Fatalf("process reentry: %v", err)
|
|
}
|
|
|
|
if tx.Type != TxTypeReentry {
|
|
t.Errorf("expected type reentry, got %s", tx.Type)
|
|
}
|
|
|
|
// Player should be reactivated
|
|
var status string
|
|
db.QueryRow("SELECT status FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&status)
|
|
if status != "active" {
|
|
t.Errorf("expected player reactivated to 'active', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestProcessReEntry_NotBusted(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "active") // Active, not busted
|
|
|
|
_, err := eng.ProcessReEntry(ctx, "t1", "p1")
|
|
if err != ErrPlayerNotBusted {
|
|
t.Fatalf("expected ErrPlayerNotBusted, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessBountyTransfer_PKO(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
db.Exec("UPDATE buyin_configs SET bounty_amount = 5000 WHERE id = ?", cfgID)
|
|
seedTournament(t, db, "t1", cfgID, true) // PKO
|
|
|
|
seedPlayer(t, db, "t1", "p1", "busted") // Eliminated
|
|
seedPlayer(t, db, "t1", "p2", "active") // Hitman
|
|
// Set bounty values
|
|
db.Exec("UPDATE tournament_players SET bounty_value = 5000 WHERE tournament_id = 't1' AND player_id = 'p1'")
|
|
db.Exec("UPDATE tournament_players SET bounty_value = 2500 WHERE tournament_id = 't1' AND player_id = 'p2'")
|
|
|
|
err := eng.ProcessBountyTransfer(ctx, "t1", "p1", "p2")
|
|
if err != nil {
|
|
t.Fatalf("process bounty transfer: %v", err)
|
|
}
|
|
|
|
// Verify: p1's bounty cleared
|
|
var p1Bounty int64
|
|
db.QueryRow("SELECT bounty_value FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p1'").Scan(&p1Bounty)
|
|
if p1Bounty != 0 {
|
|
t.Errorf("expected eliminated player bounty 0, got %d", p1Bounty)
|
|
}
|
|
|
|
// p2's bounty increased by half of p1's bounty (2500)
|
|
var p2Bounty int64
|
|
db.QueryRow("SELECT bounty_value FROM tournament_players WHERE tournament_id = 't1' AND player_id = 'p2'").Scan(&p2Bounty)
|
|
expected := int64(2500 + 2500) // original 2500 + half of 5000
|
|
if p2Bounty != expected {
|
|
t.Errorf("expected hitman bounty %d, got %d", expected, p2Bounty)
|
|
}
|
|
|
|
// Verify transactions created
|
|
var collectCount, paidCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM transactions WHERE tournament_id = 't1' AND type = 'bounty_collected'").Scan(&collectCount)
|
|
db.QueryRow("SELECT COUNT(*) FROM transactions WHERE tournament_id = 't1' AND type = 'bounty_paid'").Scan(&paidCount)
|
|
if collectCount != 1 {
|
|
t.Errorf("expected 1 bounty_collected tx, got %d", collectCount)
|
|
}
|
|
if paidCount != 1 {
|
|
t.Errorf("expected 1 bounty_paid tx, got %d", paidCount)
|
|
}
|
|
|
|
// Verify cash portion is correct (half of 5000 = 2500)
|
|
var cashAmount int64
|
|
db.QueryRow("SELECT amount FROM transactions WHERE tournament_id = 't1' AND type = 'bounty_collected' AND player_id = 'p2'").Scan(&cashAmount)
|
|
if cashAmount != 2500 {
|
|
t.Errorf("expected cash portion 2500, got %d", cashAmount)
|
|
}
|
|
}
|
|
|
|
func TestUndoTransaction(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "registered")
|
|
|
|
// Buy in
|
|
tx, err := eng.ProcessBuyIn(ctx, "t1", "p1", false)
|
|
if err != nil {
|
|
t.Fatalf("process buyin: %v", err)
|
|
}
|
|
|
|
// Undo
|
|
err = eng.UndoTransaction(ctx, tx.ID)
|
|
if err != nil {
|
|
t.Fatalf("undo transaction: %v", err)
|
|
}
|
|
|
|
// Verify transaction is marked undone
|
|
var undone bool
|
|
db.QueryRow("SELECT undone FROM transactions WHERE id = ?", tx.ID).Scan(&undone)
|
|
if !undone {
|
|
t.Error("expected transaction to be marked undone")
|
|
}
|
|
}
|
|
|
|
func TestIsLateRegistrationOpen(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
// Set level cutoff to 6 AND time cutoff to 5400 seconds (90 min)
|
|
db.Exec("UPDATE buyin_configs SET late_reg_level_cutoff = 6, late_reg_time_cutoff_seconds = 5400 WHERE id = ?", cfgID)
|
|
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
|
|
// Clock stopped: always open
|
|
open, err := eng.IsLateRegistrationOpen(ctx, "t1")
|
|
if err != nil {
|
|
t.Fatalf("check late reg: %v", err)
|
|
}
|
|
if !open {
|
|
t.Error("expected late reg open when clock stopped")
|
|
}
|
|
|
|
// Clock running, level 3: open
|
|
db.Exec("UPDATE tournaments SET clock_state = 'running', current_level = 3 WHERE id = 't1'")
|
|
open, _ = eng.IsLateRegistrationOpen(ctx, "t1")
|
|
if !open {
|
|
t.Error("expected late reg open at level 3")
|
|
}
|
|
|
|
// Clock running, level 7: closed (past level cutoff)
|
|
db.Exec("UPDATE tournaments SET current_level = 7 WHERE id = 't1'")
|
|
open, _ = eng.IsLateRegistrationOpen(ctx, "t1")
|
|
if open {
|
|
t.Error("expected late reg closed at level 7")
|
|
}
|
|
|
|
// Clock running, level 3 but time past cutoff
|
|
db.Exec("UPDATE tournaments SET current_level = 3, total_elapsed_ns = 6000000000000 WHERE id = 't1'") // 6000 seconds > 5400
|
|
open, _ = eng.IsLateRegistrationOpen(ctx, "t1")
|
|
if open {
|
|
t.Error("expected late reg closed when time past cutoff")
|
|
}
|
|
}
|
|
|
|
func TestGetTransactions(t *testing.T) {
|
|
eng, db := setupTestEngine(t)
|
|
ctx := ctxWithOperator()
|
|
|
|
cfgID := seedBuyinConfig(t, db, 10000, 15000, 2000)
|
|
seedTournament(t, db, "t1", cfgID, false)
|
|
seedPlayer(t, db, "t1", "p1", "registered")
|
|
seedPlayer(t, db, "t1", "p2", "registered")
|
|
|
|
eng.ProcessBuyIn(ctx, "t1", "p1", false)
|
|
eng.ProcessBuyIn(ctx, "t1", "p2", false)
|
|
|
|
txs, err := eng.GetTransactions(ctx, "t1")
|
|
if err != nil {
|
|
t.Fatalf("get transactions: %v", err)
|
|
}
|
|
|
|
// Should have 2 buyins + 4 rake transactions (2 per player: house + staff)
|
|
if len(txs) != 6 {
|
|
t.Errorf("expected 6 transactions, got %d", len(txs))
|
|
}
|
|
|
|
// Player-specific
|
|
ptxs, err := eng.GetPlayerTransactions(ctx, "t1", "p1")
|
|
if err != nil {
|
|
t.Fatalf("get player transactions: %v", err)
|
|
}
|
|
if len(ptxs) != 3 { // 1 buyin + 2 rake
|
|
t.Errorf("expected 3 player transactions, got %d", len(ptxs))
|
|
}
|
|
}
|
|
|
|
func TestScaleRakeSplits(t *testing.T) {
|
|
eng, _ := setupTestEngine(t)
|
|
|
|
original := []template.RakeSplit{
|
|
{Category: "house", Amount: 1200},
|
|
{Category: "staff", Amount: 800},
|
|
}
|
|
|
|
// Scale from 2000 to 1000
|
|
scaled := eng.scaleRakeSplits(original, 2000, 1000)
|
|
if len(scaled) != 2 {
|
|
t.Fatalf("expected 2 scaled splits, got %d", len(scaled))
|
|
}
|
|
|
|
// Verify sum equals new total
|
|
var sum int64
|
|
for _, s := range scaled {
|
|
sum += s.Amount
|
|
}
|
|
if sum != 1000 {
|
|
t.Errorf("expected scaled sum 1000, got %d", sum)
|
|
}
|
|
}
|