diff options
Diffstat (limited to 'internal/engine/apply_test.go')
| -rw-r--r-- | internal/engine/apply_test.go | 243 |
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) + } +} |
