Files
RenewCMS/test/activity_retention_test.go
T

344 lines
11 KiB
Go

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")
}
})
}