package test import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/infrastructure/scheduler" "RenewCMS/internal/infrastructure/useCases" "context" "errors" "log" "strings" "testing" "time" ) func TestActivityRetentionUseCases(t *testing.T) { ownerCtx := activityOwnerCtx t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) { h := newActivityHarness(t) settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.probe, h.clock).GetActivityRetention() if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 { t.Errorf("unexpected default %+v, %v", settings, err) } }) t.Run("The owner sets a valid period", func(t *testing.T) { for _, days := range []int{30, 90, 365, 0} { h := newActivityHarness(t) settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days)) if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days { t.Errorf("%d: unexpected result %+v, %v", days, settings, err) } } }) t.Run("Any other value is refused and changes nothing", func(t *testing.T) { for _, days := range []int{7, -1, 366} { h := newActivityHarness(t) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(days)) if !errors.Is(err, audit.ErrInvalidRetention) || h.settings.settings.RetentionDays != 90 || len(h.all(t)) != 0 { t.Errorf("%d: expected a refusal, got %v", days, err) } } }) t.Run("A change is recorded as a system event by the owner", func(t *testing.T) { h := newActivityHarness(t) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30)) events := h.all(t) if err != nil || len(events) != 1 { t.Fatalf("expected one event, got %d, %v", len(events), err) } event := events[0] if event.Action != "system.activity_retention_changed" || event.Category != audit.CategorySystem || event.Actor.Name != "owner" || event.Source != audit.SourceWeb || event.Outcome != audit.OutcomeSuccess || event.Security { t.Errorf("unexpected event %+v", event) } if event.Params["from"] != float64(90) || event.Params["to"] != float64(30) { t.Errorf("unexpected params %v", event.Params) } }) t.Run("Setting the same value records nothing", func(t *testing.T) { h := newActivityHarness(t) settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(90)) if err != nil || settings.RetentionDays != 90 || len(h.all(t)) != 0 || h.settings.retentionOK != 0 { t.Errorf("expected no event and no write, got %v", err) } }) t.Run("A failing save is returned and records nothing", func(t *testing.T) { h := newActivityHarness(t) h.settings.saveErr = errRepositoryFailure _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), retentionDays(30)) if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 { t.Errorf("expected the failure and no event, got %v", err) } }) } func cleanupAt(day int, hour int, minute int) time.Time { return time.Date(2026, time.October, day, hour, minute, 0, 0, time.UTC) } func activityOwnerCtx() context.Context { return audit.WithOrigin(testCtx, audit.Origin{ Actor: audit.Actor{Type: audit.ActorUser, ID: activityPtr(uint32(1)), Name: "owner"}, Source: audit.SourceWeb, }) } func newCleanup(h *activityHarness) *useCases.CleanupActivityUseCase { return useCases.NewCleanupActivityUseCase(h.probe, h.settings, h.recorder) } func TestActivityCleanupUseCase(t *testing.T) { t.Run("The cleanup is due once a day at or after 03:00", func(t *testing.T) { cases := []struct { name string last *time.Time now time.Time ran bool }{ {"never at 02:59", nil, cleanupAt(6, 2, 59), false}, {"never at 03:00", nil, cleanupAt(6, 3, 0), true}, {"never at 14:00", nil, cleanupAt(6, 14, 0), true}, {"yesterday at 02:59", ptrTime(cleanupAt(5, 3, 0)), cleanupAt(6, 2, 59), false}, {"yesterday at 03:00", ptrTime(cleanupAt(5, 3, 0)), cleanupAt(6, 3, 0), true}, {"today at 04:00", ptrTime(cleanupAt(6, 3, 0)), cleanupAt(6, 4, 0), false}, {"tomorrow at 03:00", ptrTime(cleanupAt(6, 3, 0)), cleanupAt(7, 3, 0), true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { h := newActivityHarness(t) h.settings.settings.LastCleanupAt = c.last h.now = c.now _, ran, err := newCleanup(h).Tick(c.now) if err != nil || ran != c.ran { t.Errorf("expected ran=%v, got %v, %v", c.ran, ran, err) } }) } }) t.Run("Forever never runs", func(t *testing.T) { h := newActivityHarness(t) h.settings.settings.RetentionDays = 0 _, ran, err := newCleanup(h).Tick(cleanupAt(6, 3, 0)) if err != nil || ran || h.probe.deleteCalls != 0 { t.Errorf("expected nothing to run, got ran=%v, %v, %d deletes", ran, err, h.probe.deleteCalls) } }) t.Run("The cutoff is now minus the retention days", func(t *testing.T) { h := newActivityHarness(t) h.settings.settings.RetentionDays = 30 now := cleanupAt(6, 3, 0) h.now = now var kept []uint64 for _, days := range []int{40, 31, 29, 1} { id := h.seed(t, activityUserEvent(1, "a", now.Add(-time.Duration(days)*24*time.Hour))).ID if days < 30 { kept = append(kept, id) } } result, ran, err := newCleanup(h).Tick(now) if err != nil || !ran || result.Removed != 2 { t.Fatalf("expected 2 removed, got %+v ran=%v %v", result, ran, err) } remaining := map[uint64]bool{} for _, event := range h.all(t) { remaining[event.ID] = true } for _, id := range kept { if !remaining[id] { t.Errorf("event %d should remain", id) } } last := h.settings.settings if last.LastCleanupAt == nil || !last.LastCleanupAt.Equal(now) || last.LastCleanupRemoved != 2 { t.Errorf("unexpected last cleanup %+v", last) } }) t.Run("A cleanup that removes nothing still records the run and no event", func(t *testing.T) { h := newActivityHarness(t) now := cleanupAt(6, 3, 0) h.now = now h.seed(t, activityUserEvent(1, "a", now.Add(-24*time.Hour))) result, ran, err := newCleanup(h).Tick(now) if err != nil || !ran || result.Removed != 0 { t.Fatalf("unexpected result %+v ran=%v %v", result, ran, err) } if h.settings.cleanupOK != 1 || h.settings.settings.LastCleanupAt == nil || !h.settings.settings.LastCleanupAt.Equal(now) || len(h.all(t)) != 1 { t.Errorf("expected the run recorded without event, got %+v with %d events", h.settings.settings, len(h.all(t))) } }) t.Run("A cleanup that removes events records a system event after deleting", func(t *testing.T) { h := newActivityHarness(t) now := cleanupAt(6, 3, 0) h.now = now for i := 0; i < 142; i++ { h.seed(t, activityUserEvent(1, "a", now.Add(-100*24*time.Hour))) } result, _, err := newCleanup(h).Tick(now) events := h.all(t) if err != nil || result.Removed != 142 || len(events) != 1 { t.Fatalf("expected 142 removed and one event, got %+v, %d events, %v", result, len(events), err) } event := events[0] if event.Action != "system.activity_cleanup" || event.Category != audit.CategorySystem || event.Actor.Type != audit.ActorSystem || event.Source != audit.SourceSystem || event.IP != nil || event.UserAgent != nil || event.Outcome != audit.OutcomeSuccess { t.Errorf("unexpected event %+v", event) } if event.Params["removed"] != float64(142) { t.Errorf("unexpected params %v", event.Params) } }) t.Run("A failing delete is retried at the next tick", func(t *testing.T) { h := newActivityHarness(t) now := cleanupAt(6, 3, 0) h.now = now h.probe.deleteErr = errRepositoryFailure cleanup := newCleanup(h) _, ran, err := cleanup.Tick(now) if !errors.Is(err, errRepositoryFailure) || ran || h.settings.settings.LastCleanupAt != nil { t.Fatalf("expected the failure without a recorded run, got ran=%v %v", ran, err) } h.probe.deleteErr = nil _, ran, err = cleanup.Tick(now.Add(time.Hour)) if err != nil || !ran { t.Errorf("expected the next tick to run, got ran=%v %v", ran, err) } }) t.Run("A failing save of the run is returned", func(t *testing.T) { h := newActivityHarness(t) h.settings.saveErr = errRepositoryFailure _, ran, err := newCleanup(h).Tick(cleanupAt(6, 3, 0)) if !errors.Is(err, errRepositoryFailure) || ran { t.Errorf("expected the failure, got ran=%v %v", ran, err) } }) t.Run("A shortened period applies at the next cleanup, not at the change", func(t *testing.T) { h := newActivityHarness(t) now := cleanupAt(6, 3, 0) h.now = now h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour))) _, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.probe, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), retentionDays(30)) if err != nil || len(h.all(t)) != 2 { t.Fatalf("the change itself must remove nothing, got %d events, %v", len(h.all(t)), err) } result, _, _ := newCleanup(h).Tick(now) if result.Removed != 1 { t.Errorf("expected the old event removed at the next cleanup, got %+v", result) } }) } type cleanupJobFake struct { calls chan time.Time removed int err error } func (c *cleanupJobFake) Tick(now time.Time) (audit.CleanupResult, bool, error) { c.calls <- now return audit.CleanupResult{Removed: c.removed}, c.err == nil, c.err } func TestActivityCleanupScheduler(t *testing.T) { start := func(job *cleanupJobFake) (chan time.Time, chan time.Time, *strings.Builder, context.CancelFunc, chan struct{}) { ticks := make(chan time.Time, 4) startup := make(chan time.Time, 1) logs := &strings.Builder{} runner := scheduler.NewCleanupRunner(job, func() time.Time { return cleanupAt(6, 3, 0) }, ticks, startup, log.New(logs, "", 0)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) runner.Run(ctx) }() return ticks, startup, logs, cancel, done } t.Run("The runner ticks the cleanup with the injected clock and logs the count", func(t *testing.T) { job := &cleanupJobFake{calls: make(chan time.Time, 4), removed: 142} ticks, _, logs, cancel, done := start(job) defer func() { cancel(); <-done }() ticks <- time.Time{} select { case got := <-job.calls: if !got.Equal(cleanupAt(6, 3, 0)) { t.Errorf("expected the injected clock, got %v", got) } case <-time.After(2 * time.Second): t.Fatal("the job was never ticked") } cancel() <-done if !strings.Contains(logs.String(), "activity cleanup completed: removed=142") { t.Errorf("unexpected log %q", logs.String()) } }) t.Run("A failing tick is logged", func(t *testing.T) { job := &cleanupJobFake{calls: make(chan time.Time, 4), err: errRepositoryFailure} _, startup, logs, cancel, done := start(job) startup <- time.Time{} <-job.calls cancel() <-done if !strings.Contains(logs.String(), "activity cleanup failed") || !strings.Contains(logs.String(), errRepositoryFailure.Error()) { t.Errorf("unexpected log %q", logs.String()) } }) t.Run("The scheduler stops with the process", func(t *testing.T) { job := &cleanupJobFake{calls: make(chan time.Time, 4)} _, _, _, cancel, done := start(job) cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("the runner did not return after the context was cancelled") } }) }