package test import ( "RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/retention" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/scheduler" "RenewCMS/internal/infrastructure/useCases" "context" "errors" "log" "strings" "testing" "time" ) var retentionNow = time.Date(2026, time.October, 22, 3, 0, 0, 0, time.UTC) func TestUnverifiedAccountRetentionRule(t *testing.T) { cases := []struct { name string user user.User stale bool }{ {"verified accounts are never stale", user.User{IsVerified: true, VerificationExpiration: retentionNow.Add(-365 * 24 * time.Hour)}, false}, {"a pending code is not stale", user.User{VerificationExpiration: retentionNow.Add(time.Hour)}, false}, {"an expired code inside the grace period is not stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention)}, false}, {"an expired code past the grace period is stale", user.User{VerificationExpiration: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true}, {"without an expiry, the creation date starts the grace period", user.User{CreatedAt: retentionNow.Add(-user.UnverifiedRetention - time.Second)}, true}, {"without any date, an account is kept", user.User{}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := tc.user.IsStaleUnverifiedAt(retentionNow); got != tc.stale { t.Errorf("expected %v, got %v", tc.stale, got) } }) } } func TestInviteRetentionRule(t *testing.T) { acceptedLongAgo := retentionNow.Add(-invite.Retention - time.Second) acceptedRecently := retentionNow.Add(-invite.Retention) cases := []struct { name string invite invite.Invite stale bool }{ {"a pending invite is kept", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour)}, false}, {"an invite that expired inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention)}, false}, {"an invite that expired before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention - time.Second)}, true}, {"an invite accepted inside the retention period is kept", invite.Invite{ExpiresAt: retentionNow.Add(-invite.Retention * 2), AcceptedAt: &acceptedRecently}, false}, {"an invite accepted before the retention period is stale", invite.Invite{ExpiresAt: retentionNow.Add(time.Hour), AcceptedAt: &acceptedLongAgo}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := tc.invite.IsStaleAt(retentionNow); got != tc.stale { t.Errorf("expected %v, got %v", tc.stale, got) } }) } } type retentionWorld struct { users *mockUserRepo invites *inviteFakeRepo sessions *memorySessionRepo useCase *useCases.EnforceRetentionUseCase } func newRetentionWorld() *retentionWorld { w := &retentionWorld{users: newMockUserRepo(), invites: newInviteFakeRepo(), sessions: newMemorySessionRepo()} w.useCase = useCases.NewEnforceRetentionUseCase(w.users, w.invites, w.sessions) return w } func (w *retentionWorld) seedEverything() { longAgo := retentionNow.Add(-60 * 24 * time.Hour) w.users.users[1] = user.User{ID: 1, Username: "owner", IsVerified: true, VerificationExpiration: longAgo} w.users.users[2] = user.User{ID: 2, Username: "abandoned", VerificationExpiration: longAgo} w.users.users[3] = user.User{ID: 3, Username: "pending", VerificationExpiration: retentionNow.Add(time.Hour)} w.invites.seed(invite.Invite{Email: "old-expired@example.com", ExpiresAt: longAgo}) w.invites.seed(invite.Invite{Email: "pending@example.com", ExpiresAt: retentionNow.Add(time.Hour)}) w.invites.seed(invite.Invite{Email: "old-accepted@example.com", ExpiresAt: longAgo, AcceptedAt: &longAgo}) w.sessions.revoked["expired"] = retentionNow.Add(-time.Minute) w.sessions.revoked["live"] = retentionNow.Add(time.Hour) } func TestEnforceRetentionUseCase(t *testing.T) { t.Run("Removes only what is past its retention period and reports the counts", func(t *testing.T) { w := newRetentionWorld() w.seedEverything() result, err := w.useCase.Tick(retentionNow) if err != nil { t.Fatal(err) } if result != (retention.Result{RevokedSessions: 1, UnverifiedUsers: 1, Invites: 2}) { t.Errorf("unexpected result %+v", result) } if _, kept := w.users.users[1]; !kept { t.Error("the verified owner must be kept") } if _, kept := w.users.users[3]; !kept { t.Error("the pending account must be kept") } if _, removed := w.users.users[2]; removed { t.Error("the abandoned account must be removed") } remaining := w.invites.GetAll() if len(remaining) != 1 || remaining[0].Email != "pending@example.com" { t.Errorf("expected only the pending invite kept, got %+v", remaining) } if _, kept := w.sessions.revoked["live"]; !kept || len(w.sessions.revoked) != 1 { t.Errorf("expected only the live revocation kept, got %v", w.sessions.revoked) } }) t.Run("A second run has nothing left to remove", func(t *testing.T) { w := newRetentionWorld() w.seedEverything() _, _ = w.useCase.Tick(retentionNow) result, err := w.useCase.Tick(retentionNow) if err != nil || result.Total() != 0 { t.Errorf("expected nothing removed, got %+v, %v", result, err) } }) t.Run("A session store failure stops the run before anything else is touched", func(t *testing.T) { w := newRetentionWorld() w.seedEverything() w.sessions.failure = errors.New("database unavailable") _, err := w.useCase.Tick(retentionNow) if err == nil { t.Fatal("expected an error") } if len(w.users.users) != 3 || len(w.invites.invites) != 3 { t.Error("users and invites must be untouched") } }) t.Run("A user deletion failure is reported with the work done so far", func(t *testing.T) { w := newRetentionWorld() w.seedEverything() w.users.fail.failNext("Delete") result, err := w.useCase.Tick(retentionNow) if err == nil { t.Fatal("expected an error") } if result.RevokedSessions != 1 || result.UnverifiedUsers != 0 || result.Invites != 0 { t.Errorf("unexpected partial result %+v", result) } if len(w.invites.invites) != 3 { t.Error("invites must be untouched after the failure") } }) t.Run("An invite deletion failure is reported with the work done so far", func(t *testing.T) { w := newRetentionWorld() w.seedEverything() w.invites.fail.failNext("Delete") result, err := w.useCase.Tick(retentionNow) if err == nil { t.Fatal("expected an error") } if result.RevokedSessions != 1 || result.UnverifiedUsers != 1 || result.Invites != 0 { t.Errorf("unexpected partial result %+v", result) } }) } type retentionJobFake struct { calls chan time.Time result retention.Result err error } func (f *retentionJobFake) Tick(now time.Time) (retention.Result, error) { f.calls <- now return f.result, f.err } func TestRetentionScheduler(t *testing.T) { run := func(t *testing.T, job *retentionJobFake) string { t.Helper() ticks := make(chan time.Time, 1) logs := &strings.Builder{} runner := scheduler.NewRetentionRunner(job, func() time.Time { return retentionNow }, ticks, nil, log.New(logs, "", 0)) ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) go func() { defer close(done) runner.Run(ctx) }() ticks <- time.Time{} select { case got := <-job.calls: if !got.Equal(retentionNow) { t.Errorf("expected the injected clock, got %v", got) } case <-time.After(2 * time.Second): t.Fatal("the job was never ticked") } cancel() <-done return logs.String() } t.Run("A run that removed something is logged with its counts", func(t *testing.T) { logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), result: retention.Result{RevokedSessions: 2, UnverifiedUsers: 1, Invites: 3}}) if !strings.Contains(logged, "personal data retention completed: revoked_sessions=2 unverified_users=1 invites=3") { t.Errorf("unexpected log %q", logged) } }) t.Run("A run that removed nothing stays quiet", func(t *testing.T) { if logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1)}); logged != "" { t.Errorf("expected no log, got %q", logged) } }) t.Run("A failed run is logged", func(t *testing.T) { logged := run(t, &retentionJobFake{calls: make(chan time.Time, 1), err: errors.New("database unavailable")}) if !strings.Contains(logged, "personal data retention failed: database unavailable") { t.Errorf("unexpected log %q", logged) } }) }