aboutsummaryrefslogtreecommitdiff
path: root/internal/apply/apply_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
commit3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch)
treeec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/apply/apply_test.go
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/apply/apply_test.go')
-rw-r--r--internal/apply/apply_test.go252
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)
+ }
+}