diff options
| author | tsne <tsne.dev@outlook.com> | 2026-07-06 16:52:20 +0200 |
|---|---|---|
| committer | tsne <tsne.dev@outlook.com> | 2026-07-06 21:37:42 +0200 |
| commit | 84da43998f7cc42cc72652333ac6e3d293abb23e (patch) | |
| tree | 6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/cli/cmd_prune_test.go | |
| download | hopper-main.tar.gz | |
Diffstat (limited to 'internal/cli/cmd_prune_test.go')
| -rw-r--r-- | internal/cli/cmd_prune_test.go | 244 |
1 files changed, 244 insertions, 0 deletions
diff --git a/internal/cli/cmd_prune_test.go b/internal/cli/cmd_prune_test.go new file mode 100644 index 0000000..e3b9398 --- /dev/null +++ b/internal/cli/cmd_prune_test.go @@ -0,0 +1,244 @@ +package cli + +import ( + "context" + "errors" + "sort" + "strings" + "testing" + "time" + + "tsne.dev/hopper/internal/bunny" +) + +func runPrune(source string, api storageAPI, older duration, now time.Time) error { + cmd := &pruneCommand{concurrency: 8, olderThan: older} + return cmd.prune(context.Background(), api, source, now) +} + +func days(n int) duration { return duration{days: n} } + +func sortedDeletes(f *fakeAPI) []string { + f.mu.Lock() + defer f.mu.Unlock() + out := append([]string(nil), f.deletes...) + sort.Strings(out) + return out +} + +// TestPruneSelectsCandidates covers the core §10 correctness properties: an +// orphan older than the cutoff is deleted; a file still in the release is kept +// even with an old LastChanged; an orphan newer than the cutoff is kept. +func TestPruneSelectsCandidates(t *testing.T) { + arc := makeArchive(t, []tarEntry{ + {name: "index.html", body: "<html>"}, + {name: "assets/live.js", body: "x"}, + }) + now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -100) + recent := now.AddDate(0, 0, -5) + + fc := newFakeAPI() + fc.lists[""] = []bunny.Object{ + {Path: "index.html", LastChanged: old}, // live, old -> kept + {Path: "assets", IsDirectory: true}, + {Path: "orphan-old.js", Size: 3, LastChanged: old}, // orphan, old -> deleted + {Path: "orphan-new.js", LastChanged: recent}, // orphan, new -> kept + } + fc.lists["assets"] = []bunny.Object{ + {Path: "assets/live.js", LastChanged: old}, // live asset, old -> kept + {Path: "assets/gone.js", Size: 4, LastChanged: old}, // orphan, old -> deleted + } + captureLogs(t) + + if err := runPrune(arc, fc, days(30), now); err != nil { + t.Fatalf("prune: %v", err) + } + + got := sortedDeletes(fc) + want := []string{"assets/gone.js", "orphan-old.js"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("deletes = %v, want %v", got, want) + } +} + +func TestPruneAppliesPrefix(t *testing.T) { + arc := makeArchive(t, []tarEntry{{name: "app.js", body: "x"}}) + now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -100) + + fc := newFakeAPI() + fc.lists["foo"] = []bunny.Object{ + {Path: "foo/app.js", LastChanged: old}, // live (prefixed) -> kept + {Path: "foo/stale.js", LastChanged: old}, // orphan -> deleted + } + captureLogs(t) + + cmd := &pruneCommand{concurrency: 8, prefix: "foo", olderThan: days(30)} + if err := cmd.prune(context.Background(), fc, arc, now); err != nil { + t.Fatalf("prune: %v", err) + } + if got := sortedDeletes(fc); len(got) != 1 || got[0] != "foo/stale.js" { + t.Fatalf("deletes = %v, want [foo/stale.js]", got) + } +} + +func TestPruneEmptyLiveSetIsFatal(t *testing.T) { + arc := makeArchive(t, nil) + fc := newFakeAPI() + // A List failure would be reported if reached; the empty-guard must fire + // first, so no Bunny call happens. + fc.listErr = errors.New("should not be called") + captureLogs(t) + + err := runPrune(arc, fc, days(30), time.Now()) + if err == nil || !strings.Contains(err.Error(), "empty source") { + t.Fatalf("expected empty-live-set error, got %v", err) + } + if len(fc.deletes) != 0 { + t.Fatalf("expected no deletes, got %v", fc.deletes) + } +} + +func TestPruneListFailureIsFatal(t *testing.T) { + arc := makeArchive(t, []tarEntry{{name: "a.js", body: "x"}}) + fc := newFakeAPI() + fc.listErr = errors.New("boom") + captureLogs(t) + + if err := runPrune(arc, fc, days(30), time.Now()); err == nil { + t.Fatal("expected error") + } + if len(fc.deletes) != 0 { + t.Fatalf("expected no deletes, got %v", fc.deletes) + } +} + +func TestPruneDrainAndReport(t *testing.T) { + arc := makeArchive(t, []tarEntry{{name: "keep.js", body: "x"}}) + now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -100) + + fc := newFakeAPI() + fc.lists[""] = []bunny.Object{ + {Path: "keep.js", LastChanged: old}, + {Path: "a.js", LastChanged: old}, + {Path: "b.js", LastChanged: old}, + {Path: "c.js", LastChanged: old}, + } + fc.failErr = errors.New("boom") + fc.failWhen = func(path string) bool { return path == "b.js" } + _, errb := captureLogs(t) + + err := runPrune(arc, fc, days(30), now) + var exit exitError + if !errors.As(err, &exit) { + t.Fatalf("expected exitError (non-zero exit) on delete failure, got %v", err) + } + // One failed delete must not stop the others: all three orphans are attempted. + if got := sortedDeletes(fc); strings.Join(got, ",") != "a.js,b.js,c.js" { + t.Fatalf("all candidates must be attempted, got %v", got) + } + if !strings.Contains(errb.String(), "b.js") { + t.Fatalf("failure report must name b.js, got %v", errb.String()) + } +} + +// TestPruneRemovesEmptyDirsBottomUp covers §10 step 5: a directory whose only +// file is deleted is removed, and its now-empty parent is removed too. +func TestPruneRemovesEmptyDirsBottomUp(t *testing.T) { + arc := makeArchive(t, []tarEntry{{name: "index.html", body: "<html>"}}) + now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -100) + + fc := newFakeAPI() + fc.lists[""] = []bunny.Object{ + {Path: "index.html", LastChanged: old}, + {Path: "a", IsDirectory: true}, + } + fc.lists["a"] = []bunny.Object{{Path: "a/b", IsDirectory: true}} + fc.lists["a/b"] = []bunny.Object{{Path: "a/b/gone.js", Size: 3, LastChanged: old}} + captureLogs(t) + + if err := runPrune(arc, fc, days(30), now); err != nil { + t.Fatalf("prune: %v", err) + } + + got := sortedDeletes(fc) + want := []string{"a", "a/b/gone.js"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("deletes = %v, want %v", got, want) + } +} + +// TestPruneKeepsDirWithFailedChild covers §10 step 5: a directory whose file +// delete FAILED must not be removed, because the node survives. +func TestPruneKeepsDirWithFailedChild(t *testing.T) { + arc := makeArchive(t, []tarEntry{{name: "index.html", body: "<html>"}}) + now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -100) + + fc := newFakeAPI() + fc.lists[""] = []bunny.Object{ + {Path: "index.html", LastChanged: old}, + {Path: "a", IsDirectory: true}, + } + fc.lists["a"] = []bunny.Object{{Path: "a/gone.js", Size: 3, LastChanged: old}} + fc.failWhen = func(path string) bool { return path == "a/gone.js" } + fc.failErr = errors.New("boom") + captureLogs(t) + + if err := runPrune(arc, fc, days(30), now); err == nil { + t.Fatal("expected non-zero exit on file-delete failure") + } + for _, d := range fc.deletes { + if d == "a" { + t.Fatalf("dir with failed child must not be removed, got %v", fc.deletes) + } + } +} + +// TestPruneEmptyDirFailureNonFatal covers §10 step 6: an empty-dir removal +// failure is warned and counted but never flips the exit code. +func TestPruneEmptyDirFailureNonFatal(t *testing.T) { + arc := makeArchive(t, []tarEntry{{name: "index.html", body: "<html>"}}) + now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -100) + + fc := newFakeAPI() + fc.lists[""] = []bunny.Object{ + {Path: "index.html", LastChanged: old}, + {Path: "a", IsDirectory: true}, + } + fc.lists["a"] = []bunny.Object{{Path: "a/gone.js", Size: 3, LastChanged: old}} + fc.failWhen = func(path string) bool { return path == "a" } + fc.failErr = errors.New("boom") + _, errb := captureLogs(t) + + if err := runPrune(arc, fc, days(30), now); err != nil { + t.Fatalf("empty-dir failure must be non-fatal, got %v", err) + } + if !strings.Contains(errb.String(), "Error:") { + t.Fatalf("expected error on empty-dir failure, got %q", errb.String()) + } +} + +func TestPruneDryRunDeletesNothing(t *testing.T) { + arc := makeArchive(t, []tarEntry{{name: "keep.js", body: "x"}}) + now := time.Date(2024, time.June, 1, 0, 0, 0, 0, time.UTC) + old := now.AddDate(0, 0, -100) + + fc := newFakeAPI() + fc.lists[""] = []bunny.Object{ + {Path: "keep.js", LastChanged: old}, + {Path: "stale.js", LastChanged: old}, + } + dryRun = true + + if err := runPrune(arc, dryRunStorageAPI{fc}, days(30), now); err != nil { + t.Fatalf("prune: %v", err) + } + if len(fc.deletes) != 0 { + t.Fatalf("dry-run must delete nothing, got %v", fc.deletes) + } +} |