aboutsummaryrefslogtreecommitdiff
path: root/internal/journal/read_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/journal/read_test.go')
-rw-r--r--internal/journal/read_test.go413
1 files changed, 413 insertions, 0 deletions
diff --git a/internal/journal/read_test.go b/internal/journal/read_test.go
new file mode 100644
index 0000000..3ffa14d
--- /dev/null
+++ b/internal/journal/read_test.go
@@ -0,0 +1,413 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package journal
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestRunsListsNewestFirst(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
+ t1 := t0.Add(time.Hour)
+ for _, r := range []struct {
+ id string
+ at time.Time
+ dirs []string
+ }{{"A", t0, []string{"dl"}}, {"B", t1, []string{"dl", "docs"}}} {
+ w.Append(Entry{Time: r.at, Run: r.id, Action: "run-start", Status: "ok"})
+ for _, d := range r.dirs {
+ w.Append(Entry{Time: r.at, Run: r.id, Dir: d, File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
+ }
+ w.Append(Entry{Time: r.at, Run: r.id, Action: "run-end", Status: "ok"})
+ }
+ w.Close()
+
+ runs, err := Runs(path, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(runs) != 2 || runs[0].ID != "B" || runs[1].ID != "A" {
+ t.Fatalf("runs = %+v; want B then A", runs)
+ }
+ if len(runs[0].Dirs) != 2 || runs[0].Dirs[0] != "dl" || runs[0].Dirs[1] != "docs" {
+ t.Errorf("run B dirs = %v, want [dl docs] in first-seen order", runs[0].Dirs)
+ }
+ if runs[0].Counts["move"] != 2 {
+ t.Errorf("run B move count = %d, want 2", runs[0].Counts["move"])
+ }
+ if !runs[0].Start.Equal(t1) {
+ t.Errorf("run B start = %v, want %v", runs[0].Start, t1)
+ }
+ if runs, err = Runs(path, 1); err != nil || len(runs) != 1 || runs[0].ID != "B" {
+ t.Errorf("Runs(path, 1) = %+v, %v", runs, err)
+ }
+}
+
+// TestTruncatedLastLineIsSkipped: a crash mid-write must not make the log
+// unreadable - everything before the broken line still parses.
+func TestTruncatedLastLineIsSkipped(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ w.Append(Entry{Time: time.Now(), Run: "A", Action: "run-start", Status: "ok"})
+ w.Close()
+ f, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
+ f.WriteString("2026-09-11T10:02:03+02:00\tA\tdl\thalf-written")
+ f.Close()
+
+ runs, err := Runs(path, 0)
+ if err != nil {
+ t.Fatalf("a truncated final line made the whole log unreadable: %v", err)
+ }
+ if len(runs) != 1 || runs[0].ID != "A" {
+ t.Errorf("runs = %+v; want the complete run A", runs)
+ }
+}
+
+// TestRunsMarksAnUndoneRun: an undo run's run-start Detail names the run it
+// reverses, in the exact format "undo of <run id>". Runs must mark that
+// earlier run Undone, and must not mark the undo run itself.
+func TestRunsMarksAnUndoneRun(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
+ t1 := t0.Add(time.Hour)
+ w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"})
+ w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
+ w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"})
+ w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"})
+ w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "undo-move", Status: "ok", Src: "/b/x.pdf", Dst: "/a/x.pdf"})
+ w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"})
+ w.Close()
+
+ runs, err := Runs(path, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]Run{}
+ for _, r := range runs {
+ byID[r.ID] = r
+ }
+ if !byID["A"].Undone {
+ t.Errorf("run A = %+v, want Undone", byID["A"])
+ }
+ if byID["B"].Undone {
+ t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"])
+ }
+}
+
+// TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined is fix wave item 2
+// (Important) / final-wave item 17: an undo run's run-start Detail alone
+// used to be enough for Runs to mark the original run Undone, even when the
+// undo run went on to decline every file (spec §9's "declined files are
+// logged even though nothing happens to them", extended to undo) and
+// reversed nothing at all. Reproduced by the reviewer via pty: `krino log`
+// told the user a run had been undone when the file was still filed. Run B
+// here carries the same run-start Detail as TestRunsMarksAnUndoneRun's, but
+// every one of its file-scoped entries is "declined", never "ok" - the
+// shape ApplyUndo logs when the front end's own review declines everything
+// - so run A must come back exactly as untouched.
+func TestRunsDoesNotMarkUndoneWhenEveryFileWasDeclined(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
+ t1 := t0.Add(time.Hour)
+ w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"})
+ w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
+ w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"})
+ w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"})
+ w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "undo-move", Status: "declined", Src: "/b/x.pdf", Dst: "/a/x.pdf"})
+ w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"})
+ w.Close()
+
+ runs, err := Runs(path, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]Run{}
+ for _, r := range runs {
+ byID[r.ID] = r
+ }
+ if byID["A"].Undone {
+ t.Errorf("run A = %+v, want NOT Undone - the undo run declined every file and reversed nothing", byID["A"])
+ }
+ if byID["B"].Undone {
+ t.Errorf("run B (the undo run itself) = %+v, want not Undone", byID["B"])
+ }
+}
+
+// TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir is the coordinator's
+// tightening of fix wave item 2: "at least one ok undo-* entry" is still
+// too loose, by the same shape as the bug it fixes. A file's own chain
+// stops after a failed file-affecting reversal, but a failed or refused
+// undo-mkdir deliberately does not stop anything (internal/engine's
+// isFileAffecting draws exactly this line, and undoFile's stop-on-failure
+// check shares it) - so an undo-mkdir belonging to one file can still
+// succeed even though every file-affecting reversal in the whole run
+// failed. Here x.pdf's own undo-move fails, y.pdf's own undo-move also
+// fails, and z.pdf's undo-mkdir - tidying up a directory that turned out
+// empty, not restoring anything - is the run's only "ok" entry. Marking
+// the original run Undone from that alone would be exactly Important 2's
+// bug again, by a narrower route.
+func TestRunsDoesNotMarkUndoneWhenOnlyOkEntryIsMkdir(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ t0 := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC)
+ t1 := t0.Add(time.Hour)
+ w.Append(Entry{Time: t0, Run: "A", Action: "run-start", Status: "ok"})
+ w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"})
+ w.Append(Entry{Time: t0, Run: "A", Dir: "dl", File: "y.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/y.pdf", Dst: "/b/y.pdf"})
+ w.Append(Entry{Time: t0, Run: "A", Action: "run-end", Status: "ok"})
+ w.Append(Entry{Time: t1, Run: "B", Action: "run-start", Status: "ok", Detail: "undo of A"})
+ w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "undo-move", Status: "failed", Src: "/b/x.pdf", Dst: "/a/x.pdf"})
+ w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "y.pdf", Step: 1,
+ Action: "undo-move", Status: "failed", Src: "/b/y.pdf", Dst: "/a/y.pdf"})
+ // z.pdf's own file-affecting reversal is unrelated to x.pdf/y.pdf's
+ // failures; only its cleanup mkdir is shown here, since that mkdir is
+ // the one entry this test is about - the run's only "ok" line.
+ w.Append(Entry{Time: t1, Run: "B", Dir: "dl", File: "z.pdf", Step: 2,
+ Action: "undo-mkdir", Status: "ok", Src: "/a/Work"})
+ w.Append(Entry{Time: t1, Run: "B", Action: "run-end", Status: "ok"})
+ w.Close()
+
+ runs, err := Runs(path, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]Run{}
+ for _, r := range runs {
+ byID[r.ID] = r
+ }
+ if byID["A"].Undone {
+ t.Errorf("run A = %+v, want NOT Undone - the run's only ok entry is an undo-mkdir (tidiness, not a restoration), and every file-affecting reversal failed", byID["A"])
+ }
+}
+
+// TestEntriesReportsAMangledLine: a corrupt line that is not the log's
+// final line must not be silently dropped by Entries the way Runs drops it
+// - PlanUndo needs to know a step went missing so it can refuse the whole
+// run rather than half-undo a file (spec §10).
+func TestEntriesReportsAMangledLine(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Mangle line 2 (the move step) in place: corrupt its Step column so it
+ // fails to parse, without touching the line or column count of the
+ // file otherwise - the point is a bad line in the middle, not at EOF.
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("fixture has %d lines, want 3", len(lines))
+ }
+ fields := strings.Split(lines[1], "\t")
+ fields[4] = "not-a-number" // the step column
+ lines[1] = strings.Join(fields, "\t")
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err == nil {
+ t.Fatal("Entries did not report the mangled line")
+ }
+ if !strings.Contains(err.Error(), "line 2") {
+ t.Errorf("error %q does not name line 2", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d entries, want the 2 surviving (run-start, run-end): %+v", len(got), got)
+ }
+ if got[0].Action != "run-start" || got[1].Action != "run-end" {
+ t.Errorf("entries = %+v", got)
+ }
+}
+
+// TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow: when a
+// line's own Run column is destroyed, Entries cannot attribute it by
+// content - but if it falls inside runID's own window (between its
+// run-start and run-end), that possibility alone must be enough to refuse
+// rather than silently return an incomplete chain (spec §10).
+func TestEntriesFailsClosedOnUnattributableCorruptionInsideWindow(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Insert a line with no tabs at all - its Run column is unrecoverable -
+ // between the move step and run-end, i.e. inside A's window.
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("fixture has %d lines, want 3", len(lines))
+ }
+ inserted := make([]string, 0, len(lines)+1)
+ inserted = append(inserted, lines[:2]...)
+ inserted = append(inserted, "totally-mangled-no-tabs-here")
+ inserted = append(inserted, lines[2:]...)
+ if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err == nil {
+ t.Fatal("Entries did not fail closed on unattributable corruption inside the run's window")
+ }
+ if !strings.Contains(err.Error(), "line 3") {
+ t.Errorf("error %q does not name line 3", err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("got %d entries, want the 3 surviving (run-start, move, run-end): %+v", len(got), got)
+ }
+}
+
+// TestEntriesIgnoresUnattributableCorruptionOutsideWindow: the same
+// corruption shape, placed after A's run-end inside a later run B's own
+// window, must not poison A - the window scoping keeps it out.
+func TestEntriesIgnoresUnattributableCorruptionOutsideWindow(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "B", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Insert the same unattributable corruption, now inside B's window
+ // (between B's run-start and run-end), not A's.
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 5 {
+ t.Fatalf("fixture has %d lines, want 5", len(lines))
+ }
+ inserted := make([]string, 0, len(lines)+1)
+ inserted = append(inserted, lines[:4]...)
+ inserted = append(inserted, "totally-mangled-no-tabs-here")
+ inserted = append(inserted, lines[4:]...)
+ if err := os.WriteFile(path, []byte(strings.Join(inserted, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err != nil {
+ t.Fatalf("corruption outside A's window poisoned A: %v", err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("got %d entries, want A's 3: %+v", len(got), got)
+ }
+}
+
+// TestEntriesFailsClosedOnMissingRunStart: run-start is not an optional
+// marker - every run Apply writes begins with one, so its absence, once
+// other entries for the run did parse, means either corruption or a log
+// truncated at the front. Either way the chain cannot be trusted, even
+// though the window logic alone sees nothing wrong (it never opens without
+// a parsed run-start, so it never flags anything inside the gap).
+func TestEntriesFailsClosedOnMissingRunStart(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "krino.log")
+ w, _ := Open(path)
+ at := time.Date(2026, 9, 11, 10, 2, 3, 0, time.UTC)
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-start", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Dir: "dl", File: "x.pdf", Step: 1,
+ Action: "move", Status: "ok", Src: "/a/x.pdf", Dst: "/b/x.pdf"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Append(Entry{Time: at, Run: "A", Action: "run-end", Status: "ok"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Replace A's run-start line (line 1) with a line with no tabs at all -
+ // unrecoverable, like the round-1 fixtures.
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("fixture has %d lines, want 3", len(lines))
+ }
+ lines[0] = "totally-mangled-no-tabs-here"
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Entries(path, "A")
+ if err == nil {
+ t.Fatal("Entries did not fail closed on a missing run-start")
+ }
+ if !strings.Contains(err.Error(), "run-start") {
+ t.Errorf("error %q does not name the missing run-start", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d entries, want the 2 surviving (move, run-end): %+v", len(got), got)
+ }
+ if got[0].Action != "move" || got[1].Action != "run-end" {
+ t.Errorf("entries = %+v", got)
+ }
+}