diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/persistence/txn_test.go | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz | |
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/persistence/txn_test.go')
| -rw-r--r-- | internal/persistence/txn_test.go | 186 |
1 files changed, 186 insertions, 0 deletions
diff --git a/internal/persistence/txn_test.go b/internal/persistence/txn_test.go new file mode 100644 index 0000000..10a809d --- /dev/null +++ b/internal/persistence/txn_test.go @@ -0,0 +1,186 @@ +package persistence + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestTxnCommitAppliesAllOps(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a") + writeTestFile(t, filepath.Join(dir, "b.yaml"), "old-b") + writeTestFile(t, filepath.Join(dir, "c.yaml"), "old-c") + + txn := NewTransaction(journalDir) + if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil { + t.Fatal(err) + } + if err := txn.Delete(filepath.Join(dir, "b.yaml")); err != nil { + t.Fatal(err) + } + if err := txn.Move(filepath.Join(dir, "c.yaml"), filepath.Join(dir, "d.yaml")); err != nil { + t.Fatal(err) + } + if err := txn.Replace(filepath.Join(dir, "created.yaml"), []byte("created"), 0600); err != nil { + t.Fatal(err) + } + + if err := txn.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "new-a" { + t.Fatalf("replace failed: %q", got) + } + if _, err := os.Stat(filepath.Join(dir, "b.yaml")); !os.IsNotExist(err) { + t.Fatalf("delete failed: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "c.yaml")); !os.IsNotExist(err) { + t.Fatalf("move left source behind: %v", err) + } + if got := readTestFile(t, filepath.Join(dir, "d.yaml")); got != "old-c" { + t.Fatalf("move lost content: %q", got) + } + if got := readTestFile(t, filepath.Join(dir, "created.yaml")); got != "created" { + t.Fatalf("create failed: %q", got) + } + if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { + t.Fatalf("journal not cleaned: %v", entries) + } +} + +func TestTxnRollbackOnFailure(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a") + // A file inside a read-only subdirectory makes the delete fail. + sub := filepath.Join(dir, "sub") + writeTestFile(t, filepath.Join(sub, "gone.yaml"), "old-gone") + if err := os.Chmod(sub, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(sub, 0700) }) + + txn := NewTransaction(journalDir) + if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil { + t.Fatal(err) + } + if err := txn.Delete(filepath.Join(sub, "gone.yaml")); err != nil { + t.Fatal(err) + } + if err := txn.Commit(); err == nil { + t.Fatal("expected commit to fail") + } + + if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "old-a" { + t.Fatalf("rollback did not restore a.yaml: %q", got) + } + if got := readTestFile(t, filepath.Join(sub, "gone.yaml")); got != "old-gone" { + t.Fatalf("rollback did not restore deleted file: %q", got) + } + if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { + t.Fatalf("journal not cleaned after rollback: %v", entries) + } +} + +func TestRecoverRollsBackLeftoverJournal(t *testing.T) { + // Simulate a crash: journal exists (it says a.yaml becomes new-a and + // c.yaml is deleted) but the operations never ran. + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + writeTestFile(t, filepath.Join(dir, "a.yaml"), "old-a") + writeTestFile(t, filepath.Join(dir, "c.yaml"), "old-c") + + txn := NewTransaction(journalDir) + if err := txn.Replace(filepath.Join(dir, "a.yaml"), []byte("new-a"), 0644); err != nil { + t.Fatal(err) + } + if err := txn.Delete(filepath.Join(dir, "c.yaml")); err != nil { + t.Fatal(err) + } + journal, err := txn.MarshalJournal() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(journalDir, 0700); err != nil { + t.Fatal(err) + } + if err := WriteFile(filepath.Join(journalDir, "txn-crash.json"), journal, 0600); err != nil { + t.Fatal(err) + } + + if err := Recover(journalDir); err != nil { + t.Fatalf("recover: %v", err) + } + if got := readTestFile(t, filepath.Join(dir, "a.yaml")); got != "old-a" { + t.Fatalf("recovery did not roll back a.yaml: %q", got) + } + if got := readTestFile(t, filepath.Join(dir, "c.yaml")); got != "old-c" { + t.Fatalf("recovery resurrected a delete: %q", got) + } + if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { + t.Fatalf("recovered journal not removed: %v", entries) + } +} + +func TestRecoverDropsCorruptJournal(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + if err := os.MkdirAll(journalDir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(journalDir, "txn-bad.json"), []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + if err := Recover(journalDir); err != nil { + t.Fatalf("recover corrupt: %v", err) + } + if entries, _ := ListJournalFiles(journalDir); len(entries) != 0 { + t.Fatalf("corrupt journal not removed: %v", entries) + } +} + +func TestTxnMoveOverwritesDestination(t *testing.T) { + dir := t.TempDir() + journalDir := filepath.Join(dir, ".journal") + writeTestFile(t, filepath.Join(dir, "src.yaml"), "src-content") + writeTestFile(t, filepath.Join(dir, "dst.yaml"), "dst-content") + + txn := NewTransaction(journalDir) + if err := txn.Move(filepath.Join(dir, "src.yaml"), filepath.Join(dir, "dst.yaml")); err != nil { + t.Fatal(err) + } + if err := txn.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + if got := readTestFile(t, filepath.Join(dir, "dst.yaml")); got != "src-content" { + t.Fatalf("move did not overwrite destination: %q", got) + } + if _, err := os.Stat(filepath.Join(dir, "src.yaml")); !os.IsNotExist(err) { + t.Fatalf("source not removed: %v", err) + } + if !strings.HasSuffix(readTestFile(t, filepath.Join(dir, "dst.yaml")), "content") { + t.Fatal("unreachable") + } +} |
