feat: recent activity card on Home and Retention card on Settings

This commit is contained in:
fsylvain committed 2026-10-07 16:01:28 +02:00
1 parent 35db442cd3
commit 43469ef08d
33 files changed
+2748 -204

No files matched your search

+3 -3
View File
@@ -81,8 +81,8 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(e.keys, e.users, h.clock, h.recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, h.clock),
GetSettingsUseCase: useCases.NewGetSettingsUseCase("v-test"),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(h.settings, h.clock),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock),
ListActivityUseCase: useCases.NewListActivityUseCase(h.probe, e.users, e.articles, newMockImageRepo(), e.keys, h.clock),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(h.probe, h.clock),
ExportActivityUseCase: useCases.NewExportActivityUseCase(h.probe, h.clock),
@@ -670,7 +670,7 @@ func TestActivityLogBackendRetentionSetting(t *testing.T) {
rec := e.get("/v1/settings", asUser(e, activityEditorID))
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0}`, rec.Body.String())
assertJSONEqual(t, `{"version":"v-test","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00Z"}`, rec.Body.String())
})
t.Run("The last cleanup is reported", func(t *testing.T) {
+255
View File
@@ -0,0 +1,255 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"testing"
"time"
)
var settingsTestZone = time.FixedZone("server", 2*60*60)
func settingsTestClock() time.Time {
return time.Date(2026, time.October, 6, 10, 0, 0, 0, settingsTestZone)
}
func TestSettingsNextCleanupDomain(t *testing.T) {
cases := []struct {
name string
days int
now time.Time
next string
}{
{"before 03:00 today", 90, time.Date(2026, 10, 6, 2, 59, 59, 0, settingsTestZone), "2026-10-06T03:00:00+02:00"},
{"mid day", 90, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{"exactly 03:00 is strictly after", 90, time.Date(2026, 10, 6, 3, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{"year end", 30, time.Date(2026, 12, 31, 23, 30, 0, 0, settingsTestZone), "2027-01-01T03:00:00+02:00"},
{"365 days", 365, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{"forever", 0, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := audit.Settings{RetentionDays: c.days}.WithNextCleanup(c.now).NextCleanupAt
if c.next == "" {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if got == nil || got.Format(time.RFC3339) != c.next {
t.Fatalf("expected %s, got %v", c.next, got)
}
})
}
}
func TestSettingsNextCleanupUseCases(t *testing.T) {
t.Run("Get returns the next cleanup from the clock or nil for Forever", func(t *testing.T) {
fake := newActivitySettingsFake()
get := useCases.NewGetActivityRetentionUseCase(fake, settingsTestClock)
current, err := get.GetActivityRetention()
if err != nil || current.NextCleanupAt == nil || current.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
t.Fatalf("unexpected %+v, %v", current, err)
}
fake.settings.RetentionDays = 0
current, err = get.GetActivityRetention()
if err != nil || current.NextCleanupAt != nil {
t.Fatalf("expected nil next cleanup, got %+v, %v", current, err)
}
})
t.Run("Update returns the next cleanup of the new period", func(t *testing.T) {
fake := newActivitySettingsFake()
update := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock)
forever, err := update.UpdateActivityRetention(testCtx, 0)
if err != nil || forever.RetentionDays != 0 || forever.NextCleanupAt != nil {
t.Fatalf("unexpected %+v, %v", forever, err)
}
back, err := update.UpdateActivityRetention(testCtx, 365)
if err != nil || back.NextCleanupAt == nil || back.NextCleanupAt.Format(time.RFC3339) != "2026-10-07T03:00:00+02:00" {
t.Fatalf("unexpected %+v, %v", back, err)
}
same, err := update.UpdateActivityRetention(testCtx, 365)
if err != nil || same.NextCleanupAt == nil {
t.Fatalf("unexpected %+v, %v", same, err)
}
})
t.Run("Update refuses an invalid period and saves nothing", func(t *testing.T) {
fake := newActivitySettingsFake()
_, err := useCases.NewUpdateActivityRetentionUseCase(fake, discardRecorder, settingsTestClock).UpdateActivityRetention(testCtx, 7)
if !errors.Is(err, audit.ErrInvalidRetention) || fake.retentionOK != 0 {
t.Fatalf("expected a refusal, got %v", err)
}
})
}
func TestSettingsNextCleanupHttp(t *testing.T) {
keys := func(t *testing.T, body string) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal([]byte(body), &decoded); err != nil {
t.Fatalf("invalid json %q: %v", body, err)
}
return decoded
}
t.Run("GET keeps the existing fields and adds exactly one", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.LastCleanupAt = activityPtr(time.Date(2026, time.October, 6, 1, 0, 0, 0, time.UTC))
e.settings.settings.LastCleanupRemoved = 142
rec := e.get("/v1/settings", asUser(e, activityEditorID))
body := keys(t, rec.Body.String())
if rec.Code != http.StatusOK || len(body) != 5 || body["activity_next_cleanup_at"] != "2026-10-07T03:00:00Z" || body["activity_last_cleanup_removed"] != float64(142) {
t.Errorf("unexpected %d %v", rec.Code, body)
}
})
t.Run("GET is null for Forever and open to an admin", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.RetentionDays = 0
rec := e.get("/v1/settings", asUser(e, activityAdminID))
body := keys(t, rec.Body.String())
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || !present || next != nil {
t.Errorf("unexpected %d %v", rec.Code, body)
}
})
t.Run("PATCH returns the next cleanup of the new period", func(t *testing.T) {
for _, c := range []struct {
from, to int
null bool
}{{90, 30, false}, {90, 0, true}, {0, 365, false}, {90, 90, false}} {
e := newActivityHTTPEnv(t, 1000)
e.settings.settings.RetentionDays = c.from
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": `+itoaSettings(c.to)+`}`, asUser(e, activityOwnerID))
body := keys(t, rec.Body.String())
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || body["activity_retention_days"] != float64(c.to) || !present || (next == nil) != c.null {
t.Errorf("%d to %d: unexpected %d %v", c.from, c.to, rec.Code, body)
}
if !c.null && next != "2026-10-07T03:00:00Z" {
t.Errorf("%d to %d: unexpected next %v", c.from, c.to, next)
}
}
})
}
func TestSettingsNextCleanupQAFlow(t *testing.T) {
t.Run("GET next cleanup follows the server clock in a +02:00 zone", func(t *testing.T) {
cases := []struct {
days int
now time.Time
next any
}{
{90, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{90, time.Date(2026, 10, 6, 2, 59, 59, 0, settingsTestZone), "2026-10-06T03:00:00+02:00"},
{90, time.Date(2026, 10, 6, 3, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{30, time.Date(2026, 12, 31, 23, 30, 0, 0, settingsTestZone), "2027-01-01T03:00:00+02:00"},
{365, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), "2026-10-07T03:00:00+02:00"},
{0, time.Date(2026, 10, 6, 10, 0, 0, 0, settingsTestZone), nil},
}
for _, c := range cases {
e := newActivityHTTPEnv(t, 1000)
e.now = c.now
e.settings.settings.RetentionDays = c.days
rec := e.get("/v1/settings", asUser(e, activityOwnerID))
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
next, present := body["activity_next_cleanup_at"]
if rec.Code != http.StatusOK || !present || next != c.next {
t.Errorf("%d at %s: unexpected %d %v", c.days, c.now, rec.Code, body)
}
}
})
t.Run("GET is open to owner, admin and editor and refused without a cookie", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
for _, id := range []uint32{activityOwnerID, activityAdminID, activityEditorID} {
if rec := e.get("/v1/settings", asUser(e, id)); rec.Code != http.StatusOK {
t.Errorf("user %d: expected 200, got %d", id, rec.Code)
}
}
if rec := e.get("/v1/settings"); rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
})
t.Run("PATCH by an editor is refused and changes nothing", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityEditorID))
assertPlain(t, rec, http.StatusForbidden, apperr.OwnerRequiredErrorMessage)
if e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
t.Error("the editor changed something")
}
})
t.Run("PATCH with the saved value saves nothing and records nothing", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
rec := e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 90}`, asUser(e, activityOwnerID))
if rec.Code != http.StatusOK || e.settings.retentionOK != 0 || len(e.all(t)) != 0 {
t.Errorf("unexpected %d, saves %d, events %d", rec.Code, e.settings.retentionOK, len(e.all(t)))
}
})
t.Run("A failing settings repository answers 500", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
e.settings.getErr = errors.New("database is down")
assertPlain(t, e.get("/v1/settings", asUser(e, activityOwnerID)), http.StatusInternalServerError, "Internal server error")
assertPlain(t, e.send(http.MethodPatch, "/v1/settings", `{"activity_retention_days": 30}`, asUser(e, activityOwnerID)), http.StatusInternalServerError, "Internal server error")
})
t.Run("The next cleanup stays at 03:00 local on a DST change day", func(t *testing.T) {
paris, err := time.LoadLocation("Europe/Paris")
if err != nil {
t.Skip("tzdata unavailable")
}
for _, c := range []struct {
now time.Time
next string
}{
{time.Date(2026, 10, 24, 10, 0, 0, 0, paris), "2026-10-25T03:00:00+01:00"},
{time.Date(2026, 10, 25, 1, 0, 0, 0, paris), "2026-10-25T03:00:00+01:00"},
{time.Date(2026, 10, 25, 10, 0, 0, 0, paris), "2026-10-26T03:00:00+01:00"},
{time.Date(2026, 3, 28, 10, 0, 0, 0, paris), "2026-03-29T03:00:00+02:00"},
} {
got := audit.Settings{RetentionDays: 90}.WithNextCleanup(c.now).NextCleanupAt
if got == nil || got.Format(time.RFC3339) != c.next || got.Hour() != audit.CleanupHour {
t.Errorf("from %s expected %s, got %v", c.now, c.next, got)
}
}
})
}
func itoaSettings(n int) string {
switch n {
case 0:
return "0"
case 30:
return "30"
case 90:
return "90"
}
return "365"
}
+57
View File
@@ -0,0 +1,57 @@
package test
import (
"testing"
"time"
)
func TestActivityRecentPageSize(t *testing.T) {
const homeCardQuery = "/v1/activity?size=5"
t.Run("The query the Home card sends returns the first five rows", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
var newest []uint64
for minute := 1; minute <= 8; minute++ {
event := e.seedBy(t, userActor(activityOwnerID, "Marta Alves"), time.Duration(minute)*time.Minute)
if minute <= 5 {
newest = append(newest, event.ID)
}
}
list := decodeActivityList(t, e.get(homeCardQuery, asUser(e, activityOwnerID)))
if list.Size != 5 || list.Page != 1 || list.Total != 8 || len(list.Items) != 5 {
t.Fatalf("unexpected page %d/%d total %d with %d items", list.Page, list.Size, list.Total, len(list.Items))
}
if !sameIDs(list.ids(), newest) {
t.Errorf("expected %v, got %v", newest, list.ids())
}
})
t.Run("A non-owner at size 5 only gets their own events", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
var own []uint64
for minute := 1; minute <= 7; minute++ {
event := e.seedBy(t, userActor(activityEditorID, "ed"), time.Duration(minute)*time.Minute)
if minute <= 5 {
own = append(own, event.ID)
}
}
for minute := 1; minute <= 3; minute++ {
e.seedBy(t, userActor(activityOwnerID, "Marta Alves"), time.Duration(minute)*time.Second)
}
list := decodeActivityList(t, e.get(homeCardQuery, asUser(e, activityEditorID)))
if list.Size != 5 || list.Total != 7 || !sameIDs(list.ids(), own) {
t.Errorf("expected own %v of 7, got %v of %d (size %d)", own, list.ids(), list.Total, list.Size)
}
})
t.Run("Sizes other than 5, 25, 50 and 100 stay refused", func(t *testing.T) {
for _, size := range []string{"4", "6", "10", "200"} {
e := newActivityHTTPEnv(t, 1000)
assertPlain(t, e.get("/v1/activity?size="+size, asUser(e, activityOwnerID)), 400, activityMismatch)
}
})
}
+7 -7
View File
@@ -18,7 +18,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("The default is 90 days with no cleanup yet", func(t *testing.T) {
h := newActivityHarness(t)
settings, err := useCases.NewGetActivityRetentionUseCase(h.settings).GetActivityRetention()
settings, err := useCases.NewGetActivityRetentionUseCase(h.settings, h.clock).GetActivityRetention()
if err != nil || settings.RetentionDays != 90 || settings.LastCleanupAt != nil || settings.LastCleanupRemoved != 0 {
t.Errorf("unexpected default %+v, %v", settings, err)
@@ -29,7 +29,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{30, 90, 365, 0} {
h := newActivityHarness(t)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), days)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), days)
if err != nil || settings.RetentionDays != days || h.settings.settings.RetentionDays != days {
t.Errorf("%d: unexpected result %+v, %v", days, settings, err)
@@ -41,7 +41,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
for _, days := range []int{7, -1, 366} {
h := newActivityHarness(t)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), days)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 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)
@@ -52,7 +52,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
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.recorder).UpdateActivityRetention(ownerCtx(), 30)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
events := h.all(t)
if err != nil || len(events) != 1 {
@@ -70,7 +70,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
t.Run("Setting the same value records nothing", func(t *testing.T) {
h := newActivityHarness(t)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 90)
settings, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 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)
@@ -81,7 +81,7 @@ func TestActivityRetentionUseCases(t *testing.T) {
h := newActivityHarness(t)
h.settings.saveErr = errRepositoryFailure
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(ownerCtx(), 30)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(ownerCtx(), 30)
if !errors.Is(err, errRepositoryFailure) || len(h.all(t)) != 0 {
t.Errorf("expected the failure and no event, got %v", err)
@@ -254,7 +254,7 @@ func TestActivityCleanupUseCase(t *testing.T) {
h.now = now
h.seed(t, activityUserEvent(1, "a", now.Add(-40*24*time.Hour)))
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder).UpdateActivityRetention(activityOwnerCtx(), 30)
_, err := useCases.NewUpdateActivityRetentionUseCase(h.settings, h.recorder, h.clock).UpdateActivityRetention(activityOwnerCtx(), 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)
}
+7 -1
View File
@@ -67,6 +67,7 @@ func (p *activityProbeRepo) DeleteOlderThan(cutoff time.Time) (int, error) {
type activitySettingsFake struct {
settings audit.Settings
saveErr error
getErr error
retentionOK int
cleanupOK int
}
@@ -77,7 +78,12 @@ func newActivitySettingsFake() *activitySettingsFake {
return &activitySettingsFake{settings: audit.DefaultSettings()}
}
func (f *activitySettingsFake) Get() (audit.Settings, error) { return f.settings, nil }
func (f *activitySettingsFake) Get() (audit.Settings, error) {
if f.getErr != nil {
return audit.Settings{}, f.getErr
}
return f.settings, nil
}
func (f *activitySettingsFake) SaveRetention(days int) error {
if f.saveErr != nil {
+1 -1
View File
@@ -133,7 +133,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake()),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
+3 -3
View File
@@ -19,8 +19,8 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
settingsHandler := settings.NewHandler(
useCases.NewGetSettingsUseCase(version),
useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake()),
useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder),
useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), settingsTestClock),
useCases.NewUpdateActivityRetentionUseCase(newActivitySettingsFake(), discardRecorder, settingsTestClock),
)
r := chi.NewRouter()
@@ -35,7 +35,7 @@ func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version s
}
func settingsBody(version string) string {
return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0}`
return `{"version":"` + version + `","activity_retention_days":90,"activity_last_cleanup_at":null,"activity_last_cleanup_removed":0,"activity_next_cleanup_at":"2026-10-07T03:00:00+02:00"}`
}
func TestSettingsHttp(t *testing.T) {