feat: recent activity card on Home and Retention card on Settings
This commit is contained in:
1 parent
35db442cd3
commit
43469ef08d
33 files changed
+2748
-204
No files matched your search
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user