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") }