aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_rollback_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/economy_rollback_regression_test.go')
-rw-r--r--internal/game/economy_rollback_regression_test.go89
1 files changed, 89 insertions, 0 deletions
diff --git a/internal/game/economy_rollback_regression_test.go b/internal/game/economy_rollback_regression_test.go
new file mode 100644
index 0000000..bceb351
--- /dev/null
+++ b/internal/game/economy_rollback_regression_test.go
@@ -0,0 +1,89 @@
+package game
+
+// Rollback-failure regressions for the durable money journal. When a journal
+// append fails, the invariant was historically "append failed means no record
+// bytes remain". A failed rollback (the truncate, or the sync that makes it
+// durable) breaks that invariant: the record can survive while the caller
+// believes the append failed. The wallet's journal-unavailable payout fallback
+// must then still record the attempted record's ID as open-payout evidence, or
+// recovery re-applies the surviving payout and pays the player twice.
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestAppendRollbackFailureIsUncertain: /dev/full accepts the open but fails
+// the write with ENOSPC and the rollback truncate with EINVAL, so the append
+// must report that the record MAY have survived.
+func TestAppendRollbackFailureIsUncertain(t *testing.T) {
+ if _, err := os.Stat("/dev/full"); err != nil {
+ t.Skipf("/dev/full is not available: %v", err)
+ }
+ j := newEconomyJournal(t.TempDir())
+ j.path = "/dev/full"
+
+ err := j.append(economyRecord{ID: "uncertain", Kind: "payout", Status: "open"})
+ if !errors.Is(err, errJournalAppendUncertain) {
+ t.Fatalf("append rollback failure = %v, want errJournalAppendUncertain", err)
+ }
+}
+
+// TestFallbackPayoutRecordsEvidenceForSurvivingRecord: a payout whose journal
+// append fails and whose rollback cannot be proven must leave open-payout
+// evidence in the character file. A surviving record is then settled from that
+// evidence at the next startup instead of being re-applied.
+func TestFallbackPayoutRecordsEvidenceForSurvivingRecord(t *testing.T) {
+ g, p := newEvidenceHarness(t)
+
+ // Break the journal append: point it at its own directory, which cannot
+ // be opened for append or truncation. The wallet takes the file-only
+ // fallback.
+ journalPath := g.Economy.path
+ g.Economy.path = filepath.Dir(journalPath)
+ w := casinoWallet{g: g, p: p}
+ if err := w.PayCredits(20); err != nil {
+ t.Fatalf("fallback payout failed: %v", err)
+ }
+ if p.Credits != 110 {
+ t.Fatalf("fallback payout not applied: credits=%d, want 110", p.Credits)
+ }
+ if len(p.OpenPayouts) != 1 {
+ t.Fatalf("fallback payout recorded %d open-payout evidence IDs, want 1: %v",
+ len(p.OpenPayouts), p.OpenPayouts)
+ }
+ id := p.OpenPayouts[0]
+
+ // Simulate the failed append having survived in the journal (the exact
+ // state a failed rollback produces). Recovery must use the file evidence
+ // and settle it, not re-apply it.
+ g.Economy.path = journalPath
+ rec := economyRecord{
+ ID: id, Player: p.Name, PlayerID: p.ID, Kind: "payout", Tracked: true,
+ Credit: 20, Status: "open", CreditsBefore: 90, CreditsAfter: 110,
+ }
+ line, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(journalPath, append(line, '\n'), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ g.recoverEconomy()
+
+ loaded, err := g.AccountStore.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Credits != 110 {
+ t.Fatalf("balance after recovery = %d, want 110 (surviving payout settled from evidence, not re-applied)",
+ loaded.Credits)
+ }
+ if open := g.Economy.mustLoadOpen(t); len(open) != 0 {
+ t.Fatalf("records left open after recovery: %+v", open)
+ }
+}