aboutsummaryrefslogtreecommitdiff
path: root/internal/engine/roundtrip_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/engine/roundtrip_test.go
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/engine/roundtrip_test.go')
-rw-r--r--internal/engine/roundtrip_test.go161
1 files changed, 161 insertions, 0 deletions
diff --git a/internal/engine/roundtrip_test.go b/internal/engine/roundtrip_test.go
new file mode 100644
index 0000000..425dc1a
--- /dev/null
+++ b/internal/engine/roundtrip_test.go
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "krino/internal/journal"
+ "krino/internal/plan"
+)
+
+// snapshot records every file under root: path, content hash, mode and mtime.
+func snapshot(t *testing.T, root string) map[string]string {
+ t.Helper()
+ out := map[string]string{}
+ err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ fi, err := d.Info()
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(root, p)
+ sum := sha256.Sum256(b)
+ out[rel] = hex.EncodeToString(sum[:]) + " " + fi.Mode().String() + " " + fi.ModTime().UTC().Format(time.RFC3339Nano)
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+func TestApplyThenUndoRestoresTheTree(t *testing.T) {
+ h := sandbox(t)
+ dl := filepath.Join(h, "dl")
+ if err := os.MkdirAll(filepath.Join(dl, "sub"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ files := map[string]string{
+ "inv1.pdf": "invoice one",
+ "inv2.pdf": "invoice two",
+ "notes.txt": "not a pdf",
+ "sub/deep.pdf": "nested",
+ }
+ old := time.Now().Add(-2 * time.Hour)
+ for rel, body := range files {
+ p := filepath.Join(dl, rel)
+ if err := os.WriteFile(p, []byte(body), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(p, old, old); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before := snapshot(t, dl)
+
+ main := writeConfig(t, h, `(include "dl")`, map[string]string{"dl": `
+(path "~/dl")
+(recursive yes)
+(rule "pdfs" (when (type pdf)) (copy "~/backup") (move "Work/{mtime:%Y}"))
+`})
+ e, errs := Load(main)
+ if len(errs) > 0 {
+ t.Fatal(errs)
+ }
+ 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())
+ res, err := e.Apply(context.Background(), dp, approved, j, run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j.Close()
+ if res.Failed != 0 {
+ t.Fatalf("%d files failed: %+v", res.Failed, res)
+ }
+ if reflect.DeepEqual(snapshot(t, dl), before) {
+ t.Fatal("apply changed nothing")
+ }
+ if _, err := os.Stat(filepath.Join(h, "backup", "inv1.pdf")); err != nil {
+ t.Errorf("the copy did not land: %v", err)
+ }
+
+ up, err := e.PlanUndo(run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range up.Files {
+ if f.Refused != "" {
+ t.Fatalf("undo refused %s: %s", f.File, f.Refused)
+ }
+ }
+ j2, err := journal.Open(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := e.ApplyUndo(context.Background(), up, j2, journal.NewRunID(time.Now())); err != nil {
+ t.Fatal(err)
+ }
+ j2.Close()
+
+ // Verify the move cycle within dl was undone completely.
+ got := snapshot(t, dl)
+ for rel, want := range before {
+ if got[rel] != want {
+ t.Errorf("%s after undo:\n got %s\nwant %s", rel, got[rel], want)
+ }
+ }
+ for rel := range got {
+ if _, ok := before[rel]; !ok {
+ t.Errorf("%s exists after undo but did not before", rel)
+ }
+ }
+
+ // Verify the copy-undo removed all copies from ~/backup.
+ // undo-copy sends them to trash, so backup should be gone (or exist but
+ // contain none of inv1.pdf, inv2.pdf, deep.pdf).
+ backupDir := filepath.Join(h, "backup")
+ copied := []string{"inv1.pdf", "inv2.pdf", "deep.pdf"}
+ for _, name := range copied {
+ p := filepath.Join(backupDir, name)
+ if _, err := os.Stat(p); err == nil {
+ t.Errorf("copy %s still exists after undo", name)
+ } else if !os.IsNotExist(err) {
+ t.Errorf("checking %s after undo: %v", name, err)
+ }
+ }
+ // Also check that if backupDir exists, it is empty (no copies remain).
+ if entries, err := os.ReadDir(backupDir); err == nil {
+ if len(entries) > 0 {
+ t.Errorf("backup dir not empty after undo: %v", entries)
+ }
+ } else if !os.IsNotExist(err) {
+ t.Errorf("reading backup dir after undo: %v", err)
+ }
+}