aboutsummaryrefslogtreecommitdiff
path: root/internal/cli/cmd_prune_test.go
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2026-07-06 16:52:20 +0200
committertsne <tsne.dev@outlook.com>2026-07-06 21:37:42 +0200
commit84da43998f7cc42cc72652333ac6e3d293abb23e (patch)
tree6610f82df854ae86f7f34152bb05fc712ef6f314 /internal/cli/cmd_prune_test.go
downloadhopper-main.tar.gz
initialHEADmain
Diffstat (limited to 'internal/cli/cmd_prune_test.go')
-rw-r--r--internal/cli/cmd_prune_test.go244
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)
+ }
+}