diff options
Diffstat (limited to 'internal/apply/apply_test.go')
| -rw-r--r-- | internal/apply/apply_test.go | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go new file mode 100644 index 0000000..64b0176 --- /dev/null +++ b/internal/apply/apply_test.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package apply + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "krino/internal/plan" + "krino/internal/scan" + "krino/internal/trash" +) + +// chainFor builds a Chain whose File describes path as it is on disk now, so +// the executor's "changed since plan" check passes. +func chainFor(t *testing.T, root, rel string, steps ...plan.Step) plan.Chain { + t.Helper() + p := filepath.Join(root, rel) + fi, err := os.Stat(p) + if err != nil { + t.Fatal(err) + } + return plan.Chain{ + File: scan.File{Path: p, Rel: rel, Name: filepath.Base(rel), Size: fi.Size(), ModTime: fi.ModTime(), Mode: fi.Mode()}, + Steps: steps, + } +} + +func TestChainRunsStepsInOrder(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "x.pdf"), "content", 0o644) + c := chainFor(t, root, "x.pdf", + plan.Step{Kind: plan.Copy, Rule: "backup", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "B", "x.pdf")}, + plan.Step{Kind: plan.Move, Rule: "acme", Src: filepath.Join(root, "x.pdf"), Dst: filepath.Join(root, "W", "x.pdf")}, + ) + got := Chain(c) + if len(got) != 2 || got[0].Status != "ok" || got[1].Status != "ok" { + t.Fatalf("results = %+v", got) + } + if b, err := os.ReadFile(filepath.Join(root, "B", "x.pdf")); err != nil || string(b) != "content" { + t.Errorf("the copy is missing: %q %v", b, err) + } + if b, err := os.ReadFile(filepath.Join(root, "W", "x.pdf")); err != nil || string(b) != "content" { + t.Errorf("the move did not arrive: %q %v", b, err) + } + if _, err := os.Stat(filepath.Join(root, "x.pdf")); !os.IsNotExist(err) { + t.Error("the original survived the move") + } + if len(got[0].Made) == 0 { + t.Error("the created directory was not recorded in Made") + } + if got[1].Size != int64(len("content")) { + t.Errorf("Size = %d, want the size at Dst afterwards", got[1].Size) + } +} + +// TestChainStopsWhenFileChanged is the guard that matters most: a file +// rewritten between planning and applying must not be acted on at all. +func TestChainStopsWhenFileChanged(t *testing.T) { + root := t.TempDir() + src := write(t, filepath.Join(root, "x.pdf"), "planned", 0o644) + c := chainFor(t, root, "x.pdf", + plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf")}, + plan.Step{Kind: plan.Rename, Rule: "b", Src: filepath.Join(root, "W", "x.pdf"), Dst: filepath.Join(root, "W", "y.pdf")}, + ) + write(t, src, "rewritten since the plan was made", 0o644) + + got := Chain(c) + if got[0].Status != "failed" || !strings.Contains(got[0].Detail, "changed since plan") { + t.Fatalf("first step = %+v; want failed \"changed since plan\"", got[0]) + } + if got[1].Status != "skipped" { + t.Errorf("second step = %+v; want skipped after the failure", got[1]) + } + if b, _ := os.ReadFile(src); string(b) != "rewritten since the plan was made" { + t.Error("the changed file was modified anyway") + } +} + +func TestChainTrashAndPermanentDelete(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share")) + t.Setenv("XDG_STATE_HOME", "") + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("XDG_CACHE_HOME", "") + + gone := write(t, filepath.Join(root, "gone.pdf"), "trash me", 0o644) + c1 := chainFor(t, root, "gone.pdf", plan.Step{Kind: plan.Trash, Rule: "dups", Src: gone}) + r1 := Chain(c1) + if r1[0].Status != "ok" || r1[0].Entry == "" { + t.Fatalf("trash step = %+v; want ok with an Entry name", r1[0]) + } + if r1[0].DisplacedEntry != "" { + t.Errorf("DisplacedEntry = %q, want empty: a plain trash step displaces nothing", r1[0].DisplacedEntry) + } + if _, err := os.Stat(gone); !os.IsNotExist(err) { + t.Error("the trashed file is still in place") + } + + nuked := write(t, filepath.Join(root, "nuked.pdf"), "unlink me", 0o644) + c2 := chainFor(t, root, "nuked.pdf", plan.Step{Kind: plan.DeletePermanent, Rule: "old", Src: nuked}) + if r2 := Chain(c2); r2[0].Status != "ok" { + t.Fatalf("permanent delete = %+v", r2[0]) + } + if _, err := os.Stat(nuked); !os.IsNotExist(err) { + t.Error("the permanently deleted file is still in place") + } +} + +func TestChainReChecksConflictAtExecutionTime(t *testing.T) { + root := t.TempDir() + src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644) + dst := filepath.Join(root, "W", "x.pdf") + c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst}) + // Something took the planned name between planning and applying. + write(t, dst, "someone else got here first", 0o644) + + got := Chain(c) + if got[0].Status != "ok" { + t.Fatalf("step = %+v", got[0]) + } + if got[0].Dst == dst { + t.Error("the executor overwrote a name that appeared after planning") + } + if !strings.HasSuffix(got[0].Dst, "x_1.pdf") { + t.Errorf("Dst = %q, want the next free name", got[0].Dst) + } + if b, _ := os.ReadFile(dst); string(b) != "someone else got here first" { + t.Error("the file that took the planned name was overwritten") + } +} + +func TestChainSkippedStepIsNotAttempted(t *testing.T) { + root := t.TempDir() + src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644) + c := chainFor(t, root, "x.pdf", + plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: filepath.Join(root, "W", "x.pdf"), Skip: "target exists"}, + ) + if got := Chain(c); got[0].Status != "skipped" || got[0].Detail != "target exists" { + t.Fatalf("result = %+v; want skipped carrying the planning reason", got[0]) + } + if _, err := os.Stat(filepath.Join(root, "W")); !os.IsNotExist(err) { + t.Error("a skipped step created its destination directory") + } + if _, err := os.Stat(src); err != nil { + t.Error("a skipped step moved the file anyway") + } + _ = time.Now +} + +// TestChainDisplacedFileRestoresFromDisplacedEntry is the fix-round-1 test: +// DisplacedEntry must be usable for undo, not merely present. It proves +// that by actually restoring the displaced file from the Trash and checking +// its content, not just that the field is non-empty. The displaced file +// sits at its own path, distinct from the step's own Dst: were the two the +// same (the ordinary overwrite shape), the mover's own file would already +// occupy that name by the time Restore ran, and Restore correctly refuses +// to land on an occupied path — this test isolates DisplacedEntry's own +// round-trip instead of also exercising that refusal. +func TestChainDisplacedFileRestoresFromDisplacedEntry(t *testing.T) { + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share")) + t.Setenv("XDG_STATE_HOME", "") + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("XDG_CACHE_HOME", "") + + src := write(t, filepath.Join(root, "x.pdf"), "mine", 0o644) + displaced := write(t, filepath.Join(root, "old", "y.pdf"), "displaced content", 0o644) + dst := filepath.Join(root, "W", "x.pdf") + + c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Move, Rule: "a", Src: src, Dst: dst, Displaces: displaced}) + + got := Chain(c) + if got[0].Status != "ok" { + t.Fatalf("step = %+v", got[0]) + } + if got[0].DisplacedEntry == "" { + t.Fatal("DisplacedEntry is empty; undo has no way to find the displaced file") + } + if got[0].Entry != "" { + t.Errorf("Entry = %q, want empty: this step is a Move, not itself a Trash step", got[0].Entry) + } + if _, err := os.Stat(displaced); !os.IsNotExist(err) { + t.Error("the displaced file is still at its old path") + } + if b, err := os.ReadFile(dst); err != nil || string(b) != "mine" { + t.Errorf("the move's own destination = %q, %v", b, err) + } + + restored, err := trash.Restore(got[0].DisplacedEntry) + if err != nil { + t.Fatalf("Restore(%q): %v", got[0].DisplacedEntry, err) + } + if restored != displaced { + t.Errorf("restored = %q, want %q", restored, displaced) + } + if b, err := os.ReadFile(restored); err != nil || string(b) != "displaced content" { + t.Errorf("restored content = %q, %v; want the displaced file's own content", b, err) + } +} + +// TestChainRunsRenameStep is the fix-round-2 gap: apply_test.go's only other +// Rename (in TestChainStopsWhenFileChanged) is always reported "skipped", +// because the Move before it is made to fail on purpose, so +// "case plan.Rename: err = os.Rename(step.Src, dst)" is never exercised by +// a passing test. A reversed-argument typo there would compile, pass every +// other test, pass make ci, and surface only as live data corruption. +func TestChainRunsRenameStep(t *testing.T) { + root := t.TempDir() + src := write(t, filepath.Join(root, "x.pdf"), "content", 0o644) + dst := filepath.Join(root, "y.pdf") + c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Rename, Rule: "a", Src: src, Dst: dst}) + + got := Chain(c) + if got[0].Status != "ok" { + t.Fatalf("step = %+v", got[0]) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Error("the old name still exists after a successful rename") + } + if b, err := os.ReadFile(dst); err != nil || string(b) != "content" { + t.Errorf("the new name = %q, %v; want the original content at the new name", b, err) + } +} + +// TestChainMadeIsOutermostFirstForNestedDirectories is the fix-round-2 gap: +// every other test creates at most one missing directory level, so +// mkdirAllTracked's outermost-first ordering is correct by trace but +// unpinned by any assertion. Task 5 removes these directories in reverse, +// so a later accidental reordering would break undo while passing +// everything else here. +func TestChainMadeIsOutermostFirstForNestedDirectories(t *testing.T) { + root := t.TempDir() + write(t, filepath.Join(root, "x.pdf"), "content", 0o644) + dst := filepath.Join(root, "A", "B", "x.pdf") + c := chainFor(t, root, "x.pdf", plan.Step{Kind: plan.Copy, Rule: "a", Src: filepath.Join(root, "x.pdf"), Dst: dst}) + + got := Chain(c) + if got[0].Status != "ok" { + t.Fatalf("step = %+v", got[0]) + } + want := []string{filepath.Join(root, "A"), filepath.Join(root, "A", "B")} + if !slices.Equal(got[0].Made, want) { + t.Errorf("Made = %v, want %v (outermost first)", got[0].Made, want) + } +} |
