summaryrefslogtreecommitdiff
path: root/internal/engine/apply_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/engine/apply_test.go')
-rw-r--r--internal/engine/apply_test.go243
1 files changed, 243 insertions, 0 deletions
diff --git a/internal/engine/apply_test.go b/internal/engine/apply_test.go
index 42724a0..a54bffc 100644
--- a/internal/engine/apply_test.go
+++ b/internal/engine/apply_test.go
@@ -1180,3 +1180,246 @@ func TestTallyFileCountsMixedOutcomesOnceEach(t *testing.T) {
t.Errorf("result = %+v, want one of each", result)
}
}
+
+// --- Plan 5, Task 1 ---
+
+// sharedDestUndoFixture builds a downloads directory with three pdf files
+// (a.pdf, b.pdf, c.pdf) and a single rule moving all of them into dest,
+// applies the move, and returns the sandbox home, the loaded engine, the
+// journal's path and the forward run's ID.
+//
+// All three files landing on one destination that this one run creates is
+// the shape that exercises the run-wide directory retry (Task 1, plan 5):
+// whichever file's chain first creates dest carries its undo-mkdir step(s),
+// and that file's own reversal typically runs while its siblings still
+// occupy dest - refusing the removal correctly, at first. dest may name a
+// nested path ("Work/Sub"): apply.mkdirAllTracked then records every
+// directory the move had to create, outermost first, and every one of them
+// still lands on that same first file's chain.
+//
+// Extracted per fix round 1 (Important 2): TestApplyUndoRemovesSharedDirectoryAfterEveryFileReverses
+// and TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone
+// used to duplicate this setup verbatim; TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst
+// needed the identical shape with only dest varying, which is what named the
+// parameter rather than hard-coding "Filed" here.
+func sharedDestUndoFixture(t *testing.T, dest string) (h string, e *Engine, logPath string, run string) {
+ t.Helper()
+ h = sandbox(t)
+ dl := filepath.Join(h, "dl")
+ if err := os.MkdirAll(dl, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // Three files that all move into ONE created destination. The bug this
+ // task fixes is that only the file whose chain first creates it ever
+ // carries the undo-mkdir step, and that step is attempted while its
+ // siblings are still inside.
+ for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if err := os.WriteFile(filepath.Join(dl, n), []byte(n), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ }
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(min-age 0s)
+(rule "pdfs" (when (type pdf)) (move "` + dest + `"))
+`})
+ loaded, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ e = loaded
+ dp, err := e.Plan(context.Background(), e.Dirs[0], plan.NewClaims())
+ if err != nil {
+ t.Fatal(err)
+ }
+ approved := map[string]bool{}
+ for _, c := range dp.Chains {
+ approved[c.File.Rel] = true
+ }
+ logPath = filepath.Join(h, ".local", "state", "krino", "krino.log")
+ j, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ run = journal.NewRunID(time.Now())
+ if _, err := e.Apply(context.Background(), dp, approved, j, run); err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+ return h, e, logPath, run
+}
+
+func TestApplyUndoRemovesSharedDirectoryAfterEveryFileReverses(t *testing.T) {
+ h, e, logPath, run := sharedDestUndoFixture(t, "Filed")
+ dl := filepath.Join(h, "dl")
+ filed := filepath.Join(dl, "Filed")
+ if _, err := os.Stat(filed); err != nil {
+ t.Fatalf("apply did not create the directory: %v", err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+ if res.Failed != 0 {
+ t.Fatalf("undo reported %d failures: %+v", res.Failed, res)
+ }
+ if _, err := os.Stat(filed); !os.IsNotExist(err) {
+ t.Errorf("undo left the created directory behind: %v", err)
+ }
+ for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if _, err := os.Stat(filepath.Join(dl, n)); err != nil {
+ t.Errorf("%s did not come back: %v", n, err)
+ }
+ }
+}
+
+// TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst pins fix round 1's
+// Important 1: retryDirRemovals must retry deepest path first. All three
+// files move into Work/Sub, so Apply's single mkdirAllTracked call creates
+// both Work and Work/Sub on the FIRST file's own chain (outermost first),
+// which means that one file's reversal carries two undo-mkdir steps, one for
+// each directory - and both are refused on that file's own turn, since the
+// other two files still sit in Work/Sub at that point.
+//
+// Retried deepest first, Work/Sub empties out and is removed, and Work -
+// now itself empty - is removed right after. Retried shallowest first
+// instead, Work is tried while Work/Sub (now empty, but not yet removed)
+// still sits inside it, so Work is refused as non-empty and never retried
+// again in this run; Work/Sub is then removed, leaving the outer Work
+// directory behind. So end state alone - no directory left over - already
+// distinguishes correct (deepest-first) ordering from inverted or dropped
+// ordering; unlike the flat-destination tests above, where only one
+// directory ever entered `retries`, this is the case built to tell the two
+// apart.
+func TestApplyUndoRetryRemovesNestedDirectoriesDeepestFirst(t *testing.T) {
+ h, e, logPath, run := sharedDestUndoFixture(t, "Work/Sub")
+ dl := filepath.Join(h, "dl")
+ work := filepath.Join(dl, "Work")
+ sub := filepath.Join(work, "Sub")
+ if _, err := os.Stat(sub); err != nil {
+ t.Fatalf("apply did not create the nested directory: %v", err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+ if res.Failed != 0 {
+ t.Fatalf("undo reported %d failures: %+v", res.Failed, res)
+ }
+ if _, err := os.Stat(sub); !os.IsNotExist(err) {
+ t.Errorf("undo left the nested directory behind: %v", err)
+ }
+ if _, err := os.Stat(work); !os.IsNotExist(err) {
+ t.Errorf("undo left the outer directory behind - retryDirRemovals is not retrying deepest path first: %v", err)
+ }
+ for _, n := range []string{"a.pdf", "b.pdf", "c.pdf"} {
+ if _, err := os.Stat(filepath.Join(dl, n)); err != nil {
+ t.Errorf("%s did not come back: %v", n, err)
+ }
+ }
+}
+
+// TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone pins
+// the journal half of the ruling on the point Task 1's brief left open: when
+// retryDirRemovals succeeds in removing a directory, it appends an
+// ADDITIONAL journal entry for it - the original "failed" undo-mkdir entry,
+// recorded on whichever file's chain first created the directory, is never
+// rewritten or removed - and, because that entry's Action is still
+// "undo-mkdir" like the first, journal.ranAnyUndoStep continues to exclude
+// it from what marks a run "(undone)" (the brief's constraint: it "cannot
+// change whether a run shows as (undone); confirm that rather than assume
+// it").
+func TestApplyUndoRetryLogsBothMkdirEntriesAndStillMarksOriginalRunUndone(t *testing.T) {
+ _, e, logPath, run := sharedDestUndoFixture(t, "Filed")
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ undoRun := journal.NewRunID(time.Now())
+ res, err := e.ApplyUndo(context.Background(), up, j2, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+ if res.Failed != 0 {
+ t.Fatalf("undo reported %d failures: %+v", res.Failed, res)
+ }
+
+ entries, err := journal.Entries(logPath, undoRun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var failed, ok *journal.Entry
+ var mkdirCount int
+ for i := range entries {
+ en := &entries[i]
+ if en.Action != "undo-mkdir" {
+ continue
+ }
+ mkdirCount++
+ switch en.Status {
+ case "failed":
+ failed = en
+ case "ok":
+ ok = en
+ }
+ }
+ if mkdirCount != 2 {
+ t.Fatalf("undo-mkdir entries = %d, want exactly 2 (the original refusal plus the retry's addition): %+v", mkdirCount, entries)
+ }
+ if failed == nil {
+ t.Fatal("the original refused undo-mkdir entry is missing - it must never be rewritten or removed")
+ }
+ if ok == nil {
+ t.Fatal("no successful undo-mkdir entry was appended for the retry")
+ }
+ if failed.Src != ok.Src {
+ t.Errorf("failed.Src = %q, ok.Src = %q; want the same directory", failed.Src, ok.Src)
+ }
+ if failed.File != ok.File {
+ t.Errorf("failed.File = %q, ok.File = %q; want the retry entry to carry the file that owned the original undo-mkdir", failed.File, ok.File)
+ }
+ if failed.Dir != ok.Dir {
+ t.Errorf("failed.Dir = %q, ok.Dir = %q; want the same directory name", failed.Dir, ok.Dir)
+ }
+
+ runs, err := e.Runs(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]bool{}
+ for _, r := range runs {
+ byID[r.ID] = r.Undone
+ }
+ if !byID[run] {
+ t.Errorf("original run %q not marked Undone, though every file came back", run)
+ }
+ if byID[undoRun] {
+ t.Errorf("the undo run %q itself must never read as Undone", undoRun)
+ }
+}