aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/correct_casino_refund_bank_test.go
blob: 42629977639c5c8b18d6c39af9e42772c8a2d710 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package casino

import "testing"

// Free-spin winnings live only in Machine.featureBank until the feature ends,
// so a graceful shutdown (or room eviction) that zeroes the bank without
// paying it would destroy winnings that had already landed. refundAll must
// settle the bank through the wallet in addition to refunding the triggering
// stake, and must keep the stake refundable (not double-paid) when either
// side of that settlement fails.

// TestMachineRefundAllPreservesBankedFeatureWinnings: a nonzero banked bonus
// survives RefundAll while the triggering stake is refunded exactly once.
func TestMachineRefundAllPreservesBankedFeatureWinnings(t *testing.T) {
	m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
	wallet := &testWallet{credits: 100}
	m.join("alice")
	m.beginSpinLocked(10, wallet, "", "")
	m.spin = &SlotSpin{Payout: 25, ScatterCount: 3}
	m.finishSpin() // triggers the feature: 25 banked, the stake stays open
	if m.featureBank != 25 || !m.inFreeSpins {
		t.Fatalf("setup: featureBank=%v inFreeSpins=%v, want 25/true", m.featureBank, m.inFreeSpins)
	}
	if wallet.paid != 0 {
		t.Fatalf("setup: a payout was journaled mid-feature (%d)", wallet.paid)
	}

	events := m.refundAll()
	// 10 for the refunded stake plus the 25 banked credits.
	if wallet.paid != 35 {
		t.Fatalf("refundAll paid %d, want 35 (10 stake + 25 banked winnings)", wallet.paid)
	}
	if got := wallet.credits + wallet.paid; got != 125 {
		t.Fatalf("banked winnings were destroyed: balance %d, want 125", got)
	}
	if !hasMessage(events, "25") {
		t.Fatalf("missing banked-winnings settlement message: %+v", events)
	}
	if len(wallet.open) != 0 {
		t.Fatalf("stake still outstanding after shutdown: %+v", wallet.open)
	}
}

// TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded: if the banked
// settlement cannot be persisted, the triggering stake is still refunded
// exactly once (not paid twice) and the failure is reported rather than
// silently swallowing the win.
func TestMachineRefundAllFailedBankedPayoutKeepsStakeRefunded(t *testing.T) {
	m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
	wallet := &failingPayoutWallet{testWallet{credits: 100}}
	m.join("alice")
	m.beginSpinLocked(10, wallet, "", "")
	m.featureBank = 25

	events := m.refundAll()
	if wallet.paid != 10 {
		t.Fatalf("stake refund paid %d, want 10 once", wallet.paid)
	}
	if len(wallet.open) != 0 {
		t.Fatalf("stake still outstanding: %+v", wallet.open)
	}
	if !hasMessage(events, "could not be persisted") {
		t.Fatalf("a failed banked payout was not reported: %+v", events)
	}
}

// failingRefundWallet rejects every refund, mirroring the production wallet's
// persistence-failure path: the stakes stay open for recovery.
type failingRefundWallet struct {
	testWallet
}

func (w *failingRefundWallet) RefundOutstanding() (int, int, error) {
	return 0, 0, ErrWalletPersistence
}

// TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery pins the
// load-bearing stakeFailed guard in refundAll: resetSeat must not resolve
// (forfeit) the stake RefundOutstanding left open, or the recovery refund
// contract is broken. Deleting `m.stakeFailed = err != nil` fails this test.
func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) {
	m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
	wallet := &failingRefundWallet{testWallet{credits: 100}}
	m.join("alice")
	m.beginSpinLocked(10, wallet, "", "")
	if len(wallet.open) != 1 {
		t.Fatalf("setup: the stake must be open, got %+v", wallet.open)
	}
	m.featureBank = 25

	events := m.refundAll()
	if len(wallet.open) != 1 {
		t.Fatalf("a failed refund forfeited the open stake; want it preserved for recovery: %+v", wallet.open)
	}
	if wallet.paid != 0 {
		t.Fatalf("a failed refund paid %d; want 0", wallet.paid)
	}
	if !hasMessage(events, "recorded for recovery") {
		t.Fatalf("the failed refund was not reported: %+v", events)
	}
}

// TestMachineDisconnectSettlesBankedFeatureWinnings: already-landed free-spin
// winnings are not part of the forfeited in-flight wager, so a disconnect must
// settle them through the wallet rather than silently zeroing featureBank.
func TestMachineDisconnectSettlesBankedFeatureWinnings(t *testing.T) {
	m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, newTestRand())
	wallet := &testWallet{credits: 100}
	m.join("alice")
	m.beginSpinLocked(10, wallet, "", "")
	m.spin = &SlotSpin{Payout: 25, ScatterCount: 3}
	m.finishSpin() // triggers the feature: 25 banked, the stake stays open
	if m.featureBank != 25 || !m.inFreeSpins {
		t.Fatalf("setup: featureBank=%v inFreeSpins=%v, want 25/true", m.featureBank, m.inFreeSpins)
	}

	events := m.markDisconnected("alice")
	if wallet.paid != 25 {
		t.Fatalf("disconnect paid %d, want 25 banked free-spin credits", wallet.paid)
	}
	if got := wallet.credits + wallet.paid; got != 115 {
		t.Fatalf("banked winnings were destroyed on disconnect: balance %d, want 115 (100 - 10 forfeited stake + 25 banked)", got)
	}
	if !hasMessage(events, "25") {
		t.Fatalf("missing banked-winnings settlement message: %+v", events)
	}
	if len(wallet.open) != 0 {
		t.Fatalf("stake still outstanding after disconnect: %+v", wallet.open)
	}
	if m.owns("alice") {
		t.Fatal("machine still owns the disconnected player")
	}
}