package logger import ( "fmt" "os" "path/filepath" "testing" "time" ) // makeLogFile создаёт gpio-файл с заданным именем и выставляет ему ModTime. func makeLogFile(t *testing.T, dir, name string, modTime time.Time) string { t.Helper() path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte("data"), 0644); err != nil { t.Fatalf("write %s: %v", name, err) } if err := os.Chtimes(path, modTime, modTime); err != nil { t.Fatalf("chtimes %s: %v", name, err) } return path } // nameForHour формирует имя файла в формате rotation.go для момента t. func nameForHour(t time.Time) string { return fmt.Sprintf("gpio-%04d-%02d-%02d-%02d.bin", t.Year(), t.Month(), t.Day(), t.Hour()) } func exists(path string) bool { _, err := os.Stat(path) return err == nil } // TestCleanByAge_RollingWindow: старые файлы удаляются, но свежие в окне 72ч и // минимум minKeepFiles всегда сохраняются. func TestCleanByAge_RollingWindow(t *testing.T) { dir := t.TempDir() now := time.Now() // Имена файлов хранят время с точностью до часа, поэтому держим обе группы // с запасом от границы 72ч, чтобы округление до часа не решало исход теста. var oldFiles, freshFiles []string for hoursAgo := 200; hoursAgo >= 76; hoursAgo-- { // старше 72ч с запасом 4ч ft := now.Add(-time.Duration(hoursAgo) * time.Hour) oldFiles = append(oldFiles, makeLogFile(t, dir, nameForHour(ft), ft)) } for hoursAgo := 68; hoursAgo >= 1; hoursAgo-- { // свежее 72ч с запасом 4ч ft := now.Add(-time.Duration(hoursAgo) * time.Hour) freshFiles = append(freshFiles, makeLogFile(t, dir, nameForHour(ft), ft)) } r := NewRetention(72, 0, nil) deleted, _ := r.cleanByAge(dir) if deleted != len(oldFiles) { t.Errorf("удалено %d, ожидалось %d старых файлов", deleted, len(oldFiles)) } for _, p := range oldFiles { if exists(p) { t.Errorf("старый файл не удалён: %s", filepath.Base(p)) } } for _, p := range freshFiles { if !exists(p) { t.Errorf("свежий файл ошибочно удалён: %s", filepath.Base(p)) } } } // TestCleanByAge_MinKeepFloor: даже когда ВСЕ файлы старше cutoff, minKeepFiles // самых свежих остаются — директория не стирается целиком. func TestCleanByAge_MinKeepFloor(t *testing.T) { dir := t.TempDir() now := time.Now() // 5 файлов, все старше 72ч. var paths []string for i := 0; i < 5; i++ { ft := now.Add(-time.Duration(200-i) * time.Hour) paths = append(paths, makeLogFile(t, dir, nameForHour(ft), ft)) } r := NewRetention(72, 0, nil) deleted, _ := r.cleanByAge(dir) remaining := 5 - deleted if remaining != minKeepFiles { t.Errorf("осталось %d файлов, ожидалось minKeepFiles=%d", remaining, minKeepFiles) } // Должны выжить именно самые свежие (последние два по индексу). for _, p := range paths[len(paths)-minKeepFiles:] { if !exists(p) { t.Errorf("свежайший файл должен был сохраниться: %s", filepath.Base(p)) } } } // TestCleanByAge_ClockJumpGuard: имя файла «старое», но ModTime свежий // (сценарий скачка часов после NTP). Файл должен остаться. func TestCleanByAge_ClockJumpGuard(t *testing.T) { dir := t.TempDir() now := time.Now() // Достаточно файлов, чтобы floor не мешал проверке guard. // Настоящие старые файлы (и имя, и ModTime старые). oldTime := now.Add(-200 * time.Hour) for i := 0; i < 4; i++ { ft := oldTime.Add(time.Duration(i) * time.Hour) makeLogFile(t, dir, nameForHour(ft), ft) } // Файл со «старым» именем, но записанный только что. staleName := nameForHour(now.Add(-300 * time.Hour)) guarded := makeLogFile(t, dir, staleName, now) r := NewRetention(72, 0, nil) r.cleanByAge(dir) if !exists(guarded) { t.Errorf("файл со свежим ModTime не должен удаляться при скачке часов: %s", staleName) } } // TestCleanByAge_ParseFailureFallsBackToModTime: нераспознаваемое имя не должно // приводить к удалению свежего файла (fallback на ModTime). func TestCleanByAge_ParseFailureFallsBackToModTime(t *testing.T) { dir := t.TempDir() now := time.Now() // Контекст из нормальных старых файлов, чтобы был выбор для удаления. for i := 0; i < 4; i++ { ft := now.Add(-time.Duration(200-i) * time.Hour) makeLogFile(t, dir, nameForHour(ft), ft) } // Битое имя, но свежий ModTime. garbage := makeLogFile(t, dir, "gpio-garbage.bin", now) r := NewRetention(72, 0, nil) r.cleanByAge(dir) if !exists(garbage) { t.Error("файл с нераспознаваемым именем, но свежим ModTime, не должен удаляться") } // А вот битое имя со СТАРЫМ ModTime — законно удаляется как старое. dir2 := t.TempDir() for i := 0; i < 4; i++ { ft := now.Add(-time.Duration(20-i) * time.Hour) // свежие, чтобы не удалялись makeLogFile(t, dir2, nameForHour(ft), ft) } oldGarbage := makeLogFile(t, dir2, "gpio-garbage.bin", now.Add(-200*time.Hour)) r.cleanByAge(dir2) if exists(oldGarbage) { t.Error("битое имя со старым ModTime должно удаляться как старый файл") } } // TestCleanBySize_KeepsFloor: очистка по размеру не опускается ниже minKeepFiles. func TestCleanBySize_KeepsFloor(t *testing.T) { dir := t.TempDir() now := time.Now() for i := 0; i < 6; i++ { ft := now.Add(-time.Duration(6-i) * time.Hour) makeLogFile(t, dir, nameForHour(ft), ft) } // Крошечный лимит: без floor удалились бы все. r := &Retention{maxAgeHours: 0, maxSizeBytes: 1, eventLogger: nil, stopCh: make(chan struct{})} r.cleanBySize(dir) files, _ := r.collectFiles(dir) if len(files) != minKeepFiles { t.Errorf("после cleanBySize осталось %d, ожидалось minKeepFiles=%d", len(files), minKeepFiles) } } // TestParseFilenameTime: корректное имя парсится, битое даёт нулевое время. func TestParseFilenameTime(t *testing.T) { r := NewRetention(72, 0, nil) got := r.parseFilenameTime("gpio-2026-06-09-11.bin") want := time.Date(2026, 6, 9, 11, 0, 0, 0, time.Local) if !got.Equal(want) { t.Errorf("parseFilenameTime = %v, ожидалось %v", got, want) } for _, bad := range []string{"gpio-garbage.bin", "gpio-2026-XX-09-11.bin", "gpio-2026-06-09.bin"} { if !r.parseFilenameTime(bad).IsZero() { t.Errorf("для %q ожидалось нулевое время", bad) } } }