aboutsummaryrefslogtreecommitdiff
path: root/internal/game/lifecycle_txn_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/lifecycle_txn_test.go')
-rw-r--r--internal/game/lifecycle_txn_test.go160
1 files changed, 160 insertions, 0 deletions
diff --git a/internal/game/lifecycle_txn_test.go b/internal/game/lifecycle_txn_test.go
new file mode 100644
index 0000000..f575298
--- /dev/null
+++ b/internal/game/lifecycle_txn_test.go
@@ -0,0 +1,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", "pw", false)
+ 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", "pw", false)
+ 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.State = 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.State = 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")
+}