aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_rollback_regression_test.go
blob: bceb35134b039b205076254ee0a5a185efc8b48a (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
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)
	}
}