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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
|
package game
// Account lifecycle transaction regressions: same-account races, stale
// session state, and injected filesystem failures must preserve
// identity/membership with no orphaned or duplicated characters.
import (
"os"
"path/filepath"
"strings"
"testing"
"thehouseoficarus/internal/net"
)
// lifecycle setup: account "acct" with two characters and two menu-state
// sessions authenticated to it.
func setupAcct(t *testing.T, h *harness) (*net.Session, *net.Session) {
t.Helper()
h.seedAccount("acct")
h.seedCharacter("alice", "acct")
h.seedCharacter("bob", "acct")
s1 := h.connectMenu("acct")
s2 := h.connectMenu("acct")
return s1, s2
}
func assertCharFiles(t *testing.T, h *harness, want ...string) {
t.Helper()
names, err := h.g.AccountStore.ListCharacterNames()
if err != nil {
t.Fatalf("list characters: %v", err)
}
have := map[string]bool{}
for _, n := range names {
have[n] = true
}
for _, w := range want {
if !have[w] {
t.Fatalf("expected character file %s to exist; files: %v", w, names)
}
}
if len(names) != len(want) {
t.Fatalf("unexpected character files: got %v, want %v", names, want)
}
}
// TestLifecycleConcurrentRenameNoOrphan: two sessions of the same account
// rename the same character at once. Exactly one succeeds; files and the
// account list stay consistent (no orphaned characters, no duplicate list
// entries).
func TestLifecycleConcurrentRenameNoOrphan(t *testing.T) {
h := newHarness(t)
s1, s2 := setupAcct(t, h)
h.g.RunOnOwner(func() { h.g.handleRenameChar(s1, "alice") })
h.g.RunOnOwner(func() { h.g.handleRenameChar(s2, "alice") })
// Both sessions now hold a pending rename of alice; drive them to
// submit new names concurrently.
h.g.RunOnOwner(func() { h.g.handleRenameCharName(s1, "alicia") })
h.g.RunOnOwner(func() { h.g.handleRenameCharName(s2, "alicia") })
h.barrier()
// Alicia exists exactly once, alice does not.
assertCharFiles(t, h, "alicia", "bob")
if !h.g.AccountStore.CharacterExists("alicia") {
t.Fatal("renamed character missing")
}
}
// TestLifecycleStaleMenuAfterDelete: deleting a character from one session
// refreshes the other session's pending selection (it cannot confirm-delete
// a stale name afterwards).
func TestLifecycleStaleMenuAfterDelete(t *testing.T) {
h := newHarness(t)
s1, s2 := setupAcct(t, h)
// Session 2 selects alice for deletion (pending).
h.g.RunOnOwner(func() {
h.g.handleDeleteChar(s2, "alice") // asks for confirmation
})
if s2.PendingChar != "alice" {
t.Fatalf("expected pending delete of alice, got %q", s2.PendingChar)
}
// Session 1 confirms the delete first (its own pending selection is
// "alice", mirroring the same menu state).
h.g.RunOnOwner(func() {
s1.PendingChar = "alice"
h.g.handleDeleteChar(s1, "DELETE alice")
})
h.barrier()
// Session 2's pending selection must have been invalidated.
if s2.PendingChar == "alice" {
t.Fatalf("stale pending delete survived the delete from another session")
}
if s2.Account != nil {
for _, c := range s2.Account.Characters {
if strings.EqualFold(c, "alice") {
t.Fatalf("deleted character still in another session's account list")
}
}
}
}
// TestLifecycleRenameBeatsConnect: a character cannot be renamed while it
// is connected (the online check holds and no stale-name rename goes
// through).
func TestLifecycleRenameBeatsConnect(t *testing.T) {
h := newHarness(t)
h.seedAccount("carl")
h.seedCharacter("carl", "carl")
sess, conn := h.connect("carl")
h.g.RunOnOwner(func() {
// Drive the rename menu directly while the character is online.
sess.PendingChar = "carl"
sess.SetState(net.StateRenameCharName)
h.g.handleRenameCharName(sess, "carla")
})
h.barrier()
if h.g.AccountStore.CharacterExists("carla") {
t.Fatalf("rename succeeded while character was online")
}
if !h.g.AccountStore.CharacterExists("carl") {
t.Fatalf("original character file vanished")
}
_ = conn
}
// TestLifecycleInjectedFailureRollsBack: a failure mid-transaction (the
// account file cannot be written because its directory is read-only... the
// journal lives elsewhere, so use a permissions failure on the account dir)
// must leave both files untouched.
func TestLifecycleInjectedFailureRollsBack(t *testing.T) {
h := newHarness(t)
s1, _ := setupAcct(t, h)
accDir := filepath.Join(h.dir, "players", "accounts")
if err := os.Chmod(accDir, 0500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(accDir, 0700) })
// Rename via the transaction path (invoked from a menu session whose
// account dir is unwritable): the move succeeds on disk but the account
// list rewrite fails, so the whole transaction must roll back.
h.g.RunOnOwner(func() {
s1.PendingChar = "alice"
s1.SetState(net.StateRenameCharName)
h.g.handleRenameCharName(s1, "alicia")
})
h.barrier()
os.Chmod(accDir, 0700)
// The character must be untouched: no alicia, alice still present.
assertCharFiles(t, h, "alice", "bob")
}
|