Entry fees collect into a real ledger account rather than a number in a row, so tournament money obeys the same double-entry invariants as everything else and every movement is explained by a posting. Settlement distributes the entire pool: dividing a pool across percentage shares leaves a remainder, and dropping it would destroy money and break conservation, so it goes to first place. Settlement claims the tournament before paying, so two instances cannot both pay out. Cancellation refunds every entrant and asserts the pool empties exactly. 18 tests including concurrent entry, concurrent settlement, unfunded entry taking no seat, and books balancing after payout. Removes an append-only trigger that had been over-applied to entry rows. An entry is a seat reservation, not a financial record: a seat claimed but unpaid must be releasable so the player can retry once funded. The money side stays immutable because it is a ledger posting. The journey test now derives the expected payout from the published fee schedule instead of hardcoding it, so it keeps checking something real if the rake changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
6.0 KiB
Go
234 lines
6.0 KiB
Go
package ledger_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func testPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
dsn := os.Getenv("ARCADE_TEST_DSN")
|
|
if dsn == "" {
|
|
dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
|
|
}
|
|
pool, err := pgxpool.New(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Skipf("no database available: %v", err)
|
|
}
|
|
if err := pool.Ping(context.Background()); err != nil {
|
|
t.Skipf("no database available: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
// runID is fresh for each execution of the test binary. The ledger is
|
|
// append-only and never truncated, so accounts must not be shared between runs
|
|
// or balances would accumulate across them.
|
|
var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())
|
|
|
|
// uniqueKey produces an account key unique to this test and this run.
|
|
func uniqueKey(t *testing.T, label string) []byte {
|
|
t.Helper()
|
|
return []byte(fmt.Sprintf("%s-%s-%s", runID, t.Name(), label))
|
|
}
|
|
|
|
func TestPostRejectsUnbalanced(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
a, err := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = l.Post(ctx, "test", nil, []ledger.Posting{
|
|
{AccountID: a, AmountMsat: -100},
|
|
{AccountID: b, AmountMsat: 50},
|
|
})
|
|
if !errors.Is(err, ledger.ErrUnbalanced) {
|
|
t.Fatalf("got %v, want ErrUnbalanced", err)
|
|
}
|
|
}
|
|
|
|
func TestPostRejectsOverdraft(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
|
b, _ := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
|
_, err := l.Post(ctx, "test", nil, []ledger.Posting{
|
|
{AccountID: a, AmountMsat: -1_000_000},
|
|
{AccountID: b, AmountMsat: 1_000_000},
|
|
})
|
|
if !errors.Is(err, ledger.ErrInsufficientFunds) {
|
|
t.Fatalf("got %v, want ErrInsufficientFunds", err)
|
|
}
|
|
}
|
|
|
|
func TestRejectedTransactionLeavesNoTrace(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
a, _ := l.EnsurePlayer(ctx, uniqueKey(t, "a"))
|
|
b, _ := l.EnsurePlayer(ctx, uniqueKey(t, "b"))
|
|
|
|
before, err := l.Balance(ctx, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = l.Post(ctx, "test", nil, []ledger.Posting{
|
|
{AccountID: a, AmountMsat: -500},
|
|
{AccountID: b, AmountMsat: 500},
|
|
})
|
|
after, err := l.Balance(ctx, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before != after {
|
|
t.Fatalf("failed transaction changed balance: %d -> %d", before, after)
|
|
}
|
|
}
|
|
|
|
func TestConservationOfValue(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
bridge, err := l.AccountByName(ctx, "lightning_bridge")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "player"))
|
|
|
|
// Measure this account, not the system total. Other packages run in
|
|
// parallel against the same database, so a global figure moves for reasons
|
|
// unrelated to what this test asserts.
|
|
before, err := l.Balance(ctx, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := l.Deposit(ctx, p, 5000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := l.Withdraw(ctx, p, 5000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := l.Balance(ctx, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before != after {
|
|
t.Fatalf("a deposit and matching withdrawal changed the balance: %d -> %d",
|
|
before, after)
|
|
}
|
|
_ = bridge
|
|
}
|
|
|
|
func TestBalanceTracksPostings(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"))
|
|
|
|
if _, err := l.Deposit(ctx, p, 12_345); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bal, err := l.Balance(ctx, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bal != 12_345 {
|
|
t.Fatalf("balance = %d, want 12345", bal)
|
|
}
|
|
}
|
|
|
|
// Two concurrent spends of the same funds must not both succeed. The account
|
|
// row lock is what prevents a double-spend under load.
|
|
func TestConcurrentSpendsCannotOverdraw(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
from, _ := l.EnsurePlayer(ctx, uniqueKey(t, "from"))
|
|
to, _ := l.EnsurePlayer(ctx, uniqueKey(t, "to"))
|
|
|
|
if _, err := l.Deposit(ctx, from, 1000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const workers = 8
|
|
var wg sync.WaitGroup
|
|
succeeded := make([]bool, workers)
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, err := l.Transfer(ctx, from, to, 1000)
|
|
succeeded[i] = err == nil
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
wins := 0
|
|
for _, ok := range succeeded {
|
|
if ok {
|
|
wins++
|
|
}
|
|
}
|
|
if wins != 1 {
|
|
t.Fatalf("%d concurrent spends of the same 1000 msat succeeded, want 1", wins)
|
|
}
|
|
bal, _ := l.Balance(ctx, from)
|
|
if bal != 0 {
|
|
t.Fatalf("source balance = %d, want 0", bal)
|
|
}
|
|
}
|
|
|
|
func TestAppendOnlyEnforcedByDatabase(t *testing.T) {
|
|
pool := testPool(t)
|
|
l := ledger.New(pool)
|
|
ctx := context.Background()
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"))
|
|
if _, err := l.Deposit(ctx, p, 100); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := pool.Exec(ctx, `UPDATE postings SET amount_msat = 999 WHERE account_id = $1`, p)
|
|
if err == nil {
|
|
t.Fatal("UPDATE on postings succeeded; append-only trigger is not working")
|
|
}
|
|
_, err = pool.Exec(ctx, `DELETE FROM postings WHERE account_id = $1`, p)
|
|
if err == nil {
|
|
t.Fatal("DELETE on postings succeeded; append-only trigger is not working")
|
|
}
|
|
}
|
|
|
|
func TestHistoryExplainsEveryChange(t *testing.T) {
|
|
l := ledger.New(testPool(t))
|
|
ctx := context.Background()
|
|
p, _ := l.EnsurePlayer(ctx, uniqueKey(t, "p"))
|
|
if _, err := l.Deposit(ctx, p, 800); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := l.Withdraw(ctx, p, 300); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := l.History(ctx, p, 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("got %d history entries, want 2", len(entries))
|
|
}
|
|
// History is newest-first.
|
|
if entries[0].Kind != "withdraw" || entries[0].AmountMsat != -300 {
|
|
t.Fatalf("unexpected newest entry: %+v", entries[0])
|
|
}
|
|
if entries[0].BalanceAfter != 500 {
|
|
t.Fatalf("balance after withdraw = %d, want 500", entries[0].BalanceAfter)
|
|
}
|
|
}
|