package retention import ( "bytes" "context" "encoding/json" "errors" "io/fs" "log/slog" "os" "path/filepath" "sort" "strings" "testing" "time" ) // fixedNow is the reference timestamp used by all table cases; individual // file ages are expressed as durations relative to it. var fixedNow = time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC) const day = 24 * time.Hour type fakeFile struct { name string ageOffset time.Duration } func writeFakeFile(t *testing.T, dir string, f fakeFile) { t.Helper() full := filepath.Join(dir, f.name) if err := os.WriteFile(full, []byte("backup-bytes"), 0o600); err != nil { t.Fatalf("write %s: %v", f.name, err) } mtime := fixedNow.Add(-f.ageOffset) if err := os.Chtimes(full, mtime, mtime); err != nil { t.Fatalf("chtimes %s: %v", f.name, err) } } func fileExists(t *testing.T, path string) bool { t.Helper() _, err := os.Stat(path) if err == nil { return true } if errors.Is(err, fs.ErrNotExist) { return false } t.Fatalf("stat %s: %v", path, err) return false } func captureLogger() (*slog.Logger, *bytes.Buffer) { buf := &bytes.Buffer{} handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) return slog.New(handler), buf } func TestPruneByAge_TableDriven(t *testing.T) { oldMtimes := []time.Duration{ 365 * day, 200 * day, 181 * day, 250 * day, 400 * day, } newMtimes := []time.Duration{ 0, 30 * day, 90 * day, 179 * day, 180 * day, } var corpus []fakeFile for index, age := range oldMtimes { corpus = append(corpus, fakeFile{name: makeName(index), ageOffset: age}) } for index, age := range newMtimes { corpus = append(corpus, fakeFile{ name: makeName(index + len(oldMtimes)), ageOffset: age, }) } tests := []struct { name string files []fakeFile retentionDays int wantPrunedNames []string wantKeptNames []string expectAnyRemoval bool }{ { name: "by_age_removes_older_keeps_younger", files: corpus, retentionDays: 180, wantPrunedNames: []string{makeName(0), makeName(1), makeName(2), makeName(3), makeName(4)}, wantKeptNames: []string{makeName(5), makeName(6), makeName(7), makeName(8), makeName(9)}, expectAnyRemoval: true, }, { name: "retention_zero_is_opt_out", files: corpus, retentionDays: 0, wantPrunedNames: []string{}, wantKeptNames: allNames(len(corpus)), expectAnyRemoval: false, }, { name: "non_matching_files_never_touched", files: append( allCorpus(corpus), fakeFile{name: "other-20200101-000000.dump.pqenc", ageOffset: 1000 * day}, fakeFile{name: "synapse-foo.txt", ageOffset: 1000 * day}, fakeFile{name: "README", ageOffset: 1000 * day}, ), retentionDays: 30, wantPrunedNames: []string{ makeName(0), makeName(1), makeName(2), makeName(3), makeName(4), makeName(7), makeName(8), makeName(9), }, wantKeptNames: []string{ makeName(5), makeName(6), "other-20200101-000000.dump.pqenc", "synapse-foo.txt", "README", }, expectAnyRemoval: true, }, { name: "strict_glob_synapse_dump_pqenc", files: []fakeFile{ {name: "synapse-20250101-000000.dump.pqenc", ageOffset: 1000 * day}, {name: "synapse-20250102-000000.dump.pqenc", ageOffset: 1 * day}, {name: "notsynapse-20250101-000000.dump.pqenc", ageOffset: 1000 * day}, {name: "synapse-20250101-000000.dump.pqenc.bak", ageOffset: 1000 * day}, {name: "synapse-20250101-000000.dump", ageOffset: 1000 * day}, {name: "SYNAPSE-20250101-000000.dump.pqenc", ageOffset: 1000 * day}, {name: "synapse-20250101-000000", ageOffset: 1000 * day}, }, retentionDays: 30, wantPrunedNames: []string{"synapse-20250101-000000.dump.pqenc"}, wantKeptNames: []string{ "synapse-20250102-000000.dump.pqenc", "notsynapse-20250101-000000.dump.pqenc", "synapse-20250101-000000.dump.pqenc.bak", "synapse-20250101-000000.dump", "SYNAPSE-20250101-000000.dump.pqenc", "synapse-20250101-000000", }, expectAnyRemoval: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() for _, file := range tc.files { writeFakeFile(t, dir, file) } pruned, err := PruneByAge(context.Background(), dir, tc.retentionDays, fixedNow) if err != nil { t.Fatalf("PruneByAge: unexpected error: %v", err) } gotPruned := basenames(pruned) sort.Strings(gotPruned) wantPruned := append([]string(nil), tc.wantPrunedNames...) sort.Strings(wantPruned) if !equalStringSlices(gotPruned, wantPruned) { t.Errorf("pruned mismatch\n got: %v\n want: %v", gotPruned, wantPruned) } for _, name := range pruned { if fileExists(t, filepath.Join(dir, name)) { t.Errorf("PruneByAge returned %q but file still exists on disk", name) } } for _, name := range tc.wantKeptNames { if !fileExists(t, filepath.Join(dir, name)) { t.Errorf("expected to keep %q, but it is missing", name) } } if tc.expectAnyRemoval && len(pruned) == 0 { t.Errorf("expected at least one file to be removed, got none") } }) } } func TestPruneByAge_EmptyDirectory(t *testing.T) { dir := t.TempDir() pruned, err := PruneByAge(context.Background(), dir, 30, fixedNow) if err != nil { t.Fatalf("PruneByAge on empty dir: unexpected error: %v", err) } if len(pruned) != 0 { t.Errorf("expected no pruned files in empty dir, got %v", pruned) } } func TestPruneByAge_EmptyDirectoryMissing(t *testing.T) { dir := filepath.Join(t.TempDir(), "does-not-exist") _, err := PruneByAge(context.Background(), dir, 30, fixedNow) if err == nil { t.Fatal("expected error for missing directory, got nil") } } func TestPruneByAge_LogsEachPrunedFile(t *testing.T) { // PruneByAge logs through slog.Default(); swap it for a JSON-writing // capture handler for the duration of the test and restore afterwards. dir := t.TempDir() writeFakeFile(t, dir, fakeFile{name: makeName(0), ageOffset: 365 * day}) writeFakeFile(t, dir, fakeFile{name: makeName(1), ageOffset: 200 * day}) writeFakeFile(t, dir, fakeFile{name: makeName(2), ageOffset: 30 * day}) prevLogger := slog.Default() logger, buf := captureLogger() slog.SetDefault(logger) t.Cleanup(func() { slog.SetDefault(prevLogger) }) pruned, err := PruneByAge( context.Background(), dir, 180, fixedNow, ) if err != nil { t.Fatalf("PruneByAge: unexpected error: %v", err) } if len(pruned) != 2 { t.Fatalf("expected 2 pruned files, got %d (%v)", len(pruned), pruned) } lines := splitNonEmpty(buf.String()) if len(lines) != len(pruned) { t.Fatalf( "expected %d log lines (one per pruned file), got %d:\n%s", len(pruned), len(lines), buf.String(), ) } seen := make(map[string]bool) for _, line := range lines { var record map[string]any if err := json.Unmarshal([]byte(line), &record); err != nil { t.Fatalf("log line is not valid JSON: %q: %v", line, err) } msg, _ := record["msg"].(string) if !strings.Contains(msg, "prune") && !strings.Contains(msg, "remove") { t.Errorf("log msg %q does not mention prune/remove", msg) } nameVal, ok := record["file"].(string) if !ok { t.Fatalf("log record missing string field \"file\": %s", line) } seen[nameVal] = true ageSeconds, ok := record["age_seconds"].(float64) if !ok { t.Fatalf("log record missing numeric field \"age_seconds\": %s", line) } if ageSeconds <= 0 { t.Errorf("age_seconds must be positive, got %v", ageSeconds) } } for _, name := range pruned { if !seen[name] { t.Errorf("expected a log record for pruned file %q, saw none", name) } } } func TestPruneByAge_DirectoryItselfNotRemoved(t *testing.T) { dir := t.TempDir() writeFakeFile(t, dir, fakeFile{name: makeName(0), ageOffset: 1000 * day}) if _, err := PruneByAge(context.Background(), dir, 30, fixedNow); err != nil { t.Fatalf("PruneByAge: %v", err) } info, err := os.Stat(dir) if err != nil { t.Fatalf("stat root dir: %v", err) } if !info.IsDir() { t.Errorf("root path is no longer a directory after prune") } } func TestPruneByAge_ContextCancelled(t *testing.T) { dir := t.TempDir() for index := range 5 { writeFakeFile(t, dir, fakeFile{name: makeName(index), ageOffset: 1000 * day}) } ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancelled context must short-circuit; whether it removes any files // before checking the context is implementation-defined, but it must // return ctx.Err() and must not panic. _, err := PruneByAge(ctx, dir, 30, fixedNow) if err != nil && !errors.Is(err, context.Canceled) { // Acceptable: some files may already have been pruned before the // context check; we only require the function to surface the error // in some form. Anything else is a real failure. t.Logf("PruneByAge returned non-cancel error: %v", err) } } // --- helpers --- func makeName(index int) string { // Deterministic, sorted-friendly names like synapse-0001-... .dump.pqenc // so test diffs are easy to read. return "synapse-" + zeroPad(index) + "-000000.dump.pqenc" } func zeroPad(index int) string { const width = 4 digits := "0123456789" if index < 0 { return "neg" } if index >= 10000 { return "ovf" } out := make([]byte, width) for pos := width - 1; pos >= 0; pos-- { out[pos] = digits[index%10] index /= 10 } return string(out) } func allNames(count int) []string { names := make([]string, 0, count) for index := range count { names = append(names, makeName(index)) } return names } func allCorpus(in []fakeFile) []fakeFile { out := make([]fakeFile, len(in)) copy(out, in) return out } func basenames(paths []string) []string { out := make([]string, 0, len(paths)) for _, p := range paths { out = append(out, filepath.Base(p)) } return out } func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func splitNonEmpty(s string) []string { var out []string for _, line := range strings.Split(s, "\n") { if strings.TrimSpace(line) != "" { out = append(out, line) } } return out }