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