Рыба проекта. Минимальная функциональность
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user