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: ""}, {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: ""}}) 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: ""}}) 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: ""}}) 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) } }