Files
RenewCMS/features/activity_home_card_and_retention.qa.md
T

14 KiB

QA: Recent activity card on Home and Retention card on Settings

Implementation note for coder (informational, not a QA assertion)

  • Backend: add NextCleanupAt (nullable time) to the audit settings entity with a domain method computing the next 03:00 (audit.CleanupHour) strictly after a given time, in that time's location; nil when RetentionDays is audit.ForeverRetentionDays. GetActivityRetentionUseCase and UpdateActivityRetentionUseCase get an injected now func() time.Time (wired in api/dependecyInjection.go) and populate it. settingsResponse in api/handlers/settings gains activity_next_cleanup_at (*time.Time, RFC 3339, null when nil). No logic in the handler, none in the SPA.
  • Home: HomeView mounts a RecentActivityCard calling GET /v1/activity?size=5 once, reusing ActivityActor, ActivitySentence and lib/activityTime.
  • Settings: widen WorkspaceSettings with the four activity_* fields, add a PATCH helper to useSettings, render RetentionCard after MediaCleanupCard for session.role === 'owner' only. No second GET /v1/settings.
  • features/settings_page.feature and its qa.md say the body is exactly {"version"}; this feature supersedes that statement, so update those tests to the new five-key body.

Automated, Go (httptest, in-memory fakes, task test)

Build settings.NewHandler(GetSettingsUseCase, GetActivityRetentionUseCase, UpdateActivityRetentionUseCase) over a fake audit.SettingsRepository (records saved retention and last cleanup) and a fake audit.Recorder, with a fixed fake clock. Mount on a fresh chi.NewRouter mirroring NewSettingsRouter: GET "/" behind jwtauth.Verifier + Authenticator (test jwtauth.New("HS256", ...)); PATCH "/" additionally behind the owner check using a fake user repository holding an owner, an admin and an editor. Call validator.InitValidator() first. No live server, no SQLite file. Fake type names must be unique across test files.

  • G1. GET table (clock in a +02:00 zone): retention 90 at 10:00 gives next = next day 03:00+02:00; at 02:59:59 gives same day 03:00; at exactly 03:00:00 gives next day 03:00; 30 at 2026-12-31T23:30 gives 2027-01-01T03:00; 365 at 10:00 gives next day; retention 0 gives JSON null (assert the key is present with value null, not absent).
  • G2. GET body has exactly the five keys (decode into map[string]any, assert the key set); last cleanup 2026-10-06T01:00:00Z and 142 preserved.
  • G3. GET is 200 for owner, admin and editor tokens; 401 without a cookie.
  • G4. PATCH owner 90->30: 200, activity_retention_days 30, next cleanup non-null at the next 03:00; the fake repository holds 30; one audit event with params from 90, to 30.
  • G5. PATCH owner 90->0: 200, next null. PATCH 0->365: next non-null.
  • G6. PATCH owner 90->90: 200, next non-null, no repository save, no audit event.
  • G7. Ownership: PATCH as admin and as editor: 403 with "Only the owner can do this."; repository unchanged; no audit event. PATCH without a cookie: 401.
  • G8. Invalid: {"activity_retention_days": 7}, "30" (string), {}, malformed JSON: each 400 with the apperr.BodyErrorMessage text (plain text via http.Error), repository unchanged.
  • G9. Use-case level (no HTTP): GetActivityRetention returns NextCleanupAt per G1 rows; UpdateActivityRetention(7) returns an error satisfying errors.Is(err, audit.ErrInvalidRetention) and saves nothing; a repository Get error propagates and the handler answers 500 "Internal server error".
  • G10. Domain: NextCleanupAt table including a DST-change day in a named zone (for example Europe/Paris, 2026-10-25), asserting it is still 03:00 local on the right date.

Automated, SPA (Vitest + Vue Test Utils, task test:web)

Suggested files: web/src/views/tests/HomeActivityCard.spec.ts and SettingsRetention.spec.ts. Mock useApi per case; no real backend. Freeze the clock with vi.setSystemTime only where a time label is asserted (these specs are not visual).

Home (mount HomeView with a resolved session; GET /v1/article/ mocked with the 6-article fixture):

  1. Owner and admin: 5 events, title "Recent activity", "View all" links to /activity; row 1 text with semibold target, "Marta Alves · 2 min ago", avatar "MA"; the activity mock was called once with exactly size=5 and no other parameter; /summary and /export never called.
  2. Regular user (editor, absent role): "Your recent activity"; meta lines are time only; no actor parameter.
  3. Marker table over outcome x security (no badge and no shield, Failed badge, shield only, badge only).
  4. Time label table (2 min ago, 3 h ago, "Yesterday, 14:20", "3 Oct 2026, 14:20") with fixed time; the title attribute holds the full local time.
  5. Clamp class on a 150-character title; unknown action "article.archive" renders the code; auth.login_failed sentence with Failed badge and user-round-x avatar.
  6. Rows are inert: clicking one changes no route and opens nothing.
  7. Skeleton only after 150 ms (fake timers): none before, 5 skeleton rows and aria-busy after, replaced on resolve; a fast response never shows one.
  8. Error table (500, 403, network): role="alert", title "Couldn't load recent activity", no description, "Try again"; stat tiles, article list and "Connect your website" still rendered; retry sends a second GET and recovers.
  9. Empty: both copies by role; "View all" still present.
  10. Independence in both directions (articles 500 + activity 200; articles [] + activity 500).
  11. "View all" navigates to /activity (test router).
  12. Layout classes: grid with the lg two-column template and the 420 px column, and no max-width class on the Home wrapper or the grid (the grid spans the full content width); DOM order puts the card first below lg (real pixels are manual).
  13. Existing home specs (HomeView, HomeUpdateBanner, HomeUpdateReady) keep passing with the activity mock added to the shared mount helper.

Settings (mount SettingsView, GET /v1/settings mocked with the five-key body):

  1. Hidden for admin, editor and absent role; GET /v1/settings called once.
  2. Owner Default: header, "90 days", helper, lock alert, "Last cleanup 6 Oct 2026, 03:00 · 142 events removed", "Next cleanup 7 Oct 2026, 03:00" (from activity_next_cleanup_at; the SPA computes nothing); no PATCH; "1 event removed" singular.
  3. last_cleanup_at null: "Never". Forever with null/null: "Never" and "Not scheduled". Forever with a past cleanup: the real last line and "Not scheduled".
  4. Skeleton after 150 ms with aria-busy; load error alert and "Try again" re-runs the shared GET.
  5. Longer periods (90->365, 30->90, 90->Forever): one PATCH with {"activity_retention_days": N}, "Saving…" then "Saved", gone after 2 s (fake timers), no warning; the returned next cleanup replaces the line; a Forever response with next null shows "Not scheduled".
  6. Shortening: no PATCH, warning copy with the date taken from the loaded activity_next_cleanup_at; Apply sends once; Cancel reverts the select with no PATCH; table Forever->365, 365->90, 90->90 (no-op), 30->Forever (immediate); a longer choice while the warning is open drops it and leaves no stale warning after 30 days then 365 days again.
  7. Save error x (500, 400, 403, network): alert copy, select reverts, lines unchanged; Try again re-sends the same value without re-confirming; success shows "Saved"; error after Apply also clears the warning; a 403 triggers a session refresh and the card disappears when the role is no longer owner.
  8. Offline (mock useOnline false): select, Apply and Cancel disabled; nothing sent; page offline banner text unchanged.
  9. 401 handled globally: no local alert.
  10. French key parity: every new string has a messages.ts entry.
  11. Keyboard: the select opens with Enter or Space, arrows and Enter choose, Escape closes; Apply then Cancel reachable in order after the select.
  12. Existing SettingsView, SettingsMediaCleanup and Settings update specs keep passing with the widened mock body.

Visual (task visual:check)

  • Neither card can be pixel-compared (dashed placeholders); no new visual state is added. The card sub-states are not ### headings in home.md or settings.md, so the tool should not iterate them; confirm on the first run.
  • Existing Home states and owner-session Settings states must still pass at 10 % with the card present. Every Home setup in web/visual/states.ts needs GET /v1/activity?size=5 mocked (an unmocked /v1 call fails the gate), and every owner Settings setup needs the widened GET /v1/settings body. Layout changes may exceed the gate: measure first, then ask the user for a per-state exception (needs approvedBy). Do not add any exception beforehand.
  • Time-dependent states stay skip: 'clock-sensitive'; no Vitest spec freezes the date for a visual claim.

Manual verification

  • Real GET /v1/settings and PATCH /v1/settings against task dev with a live SQLite file: five keys, next cleanup at the next 03:00 server time, null after switching to Forever, a timestamp after leaving Forever; as an admin PATCH is 403, as the owner a bad value is 400.
  • Browser in a time zone different from the server's: the "Next cleanup" and "Last cleanup" lines both show the user's local time of the same instants; the warning date matches the "Next cleanup" line.
  • Real daily cleanup (or a forced run) updates "Last cleanup" and moves "Next cleanup" a day forward.
  • Real GET /v1/activity?size=5 as owner, admin and editor (server-side own-event scoping), including a site with only events older than 7 days (reads "No activity yet").
  • Home at 1440 and 390 px, light and dark: 420 px column in the far-right of the full content width, 16 px gap, row clamping, "View all" 44 px touch target; the article column fills the remaining width and still reads well.
  • Keyboard and focus: Home tab order "New article", article rows, "View all"; Settings select, Apply, Cancel in order and focus back on the select after Cancel.
  • Hover, focus rings, the 2 s "Saved" fade and reduced motion.

Retention size limit extension

Spec: rule "Retention size limit" in activity_home_card_and_retention.feature. Items 14 to 25 above are updated for the widened mock body (activity_retention_max_gb, activity_log_size_bytes) and the new helper and alert copy.

Implementation note for coder (informational, not a QA assertion):

  • audit.Settings gains RetentionMaxGB; the settings use case result carries LogSizeBytes from audit.Repository.SizeBytes.
  • audit.SizeOptions() is [0, 1, 5, 10, 25, 50]; invalid values return ErrInvalidRetention.
  • audit.Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error).
  • audit.SettingsRepository.SaveRetention takes days and max_gb; the model and mapper gain retention_max_gb.
  • The cleanup use case runs the age step when days > 0, then loops DeleteOldest batches with SizeBytes until the log fits or a batch removes 0; the cutoff is the 24 h guard.
  • The handler request struct is a partial with pointer fields and validate tags; an empty object is rejected.
  • settingsResponse gains activity_retention_max_gb and activity_log_size_bytes. Update the settings_page tests that count keys.

Go (httptest, fakes)

  • G11. GET key set is exactly the seven keys; max_gb and size_bytes carry the fake's values; a fresh fake gives 90, 0, 0.
  • G12. GET next-cleanup table: (90, 0), (90, 5), (0, 5) are non-null; (0, 0) is JSON null with the key present.
  • G13. PATCH table: size alone, age alone, both, Forever plus size, size 0 with Forever (next becomes null); the fake repository holds the pair.
  • G14. PATCH invalid: sizes 2, -1, 100, 5.5, "5", null, mixed with a valid age, {}, not JSON; each 400 with the BodyErrorMessage text and nothing saved. Non-owner 403, no cookie 401.
  • G15. No-op PATCH of both saved values: 200, no save, no event.
  • G16. Audit params table for the three bodies (age, size, both).
  • G17. Cleanup use case with a fake events repository that has a size model: age then size, Forever plus size, Forever with no size (ran=false, no calls), size already met (no DeleteOldest), the guard stops at 24 h with 0 removed and no error, DeleteOldest error propagates and leaves the last cleanup unchanged, total removed in the event params and in SaveLastCleanup, the batch loop terminates when a batch removes 0.
  • G18. CleanupDue table unchanged; Tick runs for Forever when a size limit is set.
  • G19. Domain: SizeOptions validation; next-cleanup is nil only when both are 0.
  • G20. Persistence on in-memory SQLite (extend test/activity_persistence_test.go): SizeBytes with multi-byte text, the empty table, after delete; DeleteOldest with the limit and the guard; the migrated model has retention_max_gb defaulting to 0.
  • G21. The interface-shape test is updated to the six methods.
  • G22. A SizeBytes error propagates and the handler answers 500 "Internal server error".

SPA (Vitest), extending web/src/views/tests/SettingsRetention.spec.ts

    1. Default 90 days or 5 GB; Age only; Size only (forever); Forever without a size: selects, helpers, "Currently 3.2 GB.", the "or" divider, the lines.
    1. Size formatting table (3.2, "Less than 0.1 GB", 5.0); 1024^3 base.
    1. Larger size or No size limit: one PATCH with only the size key, "Saving…" then "Saved" (fake timers).
    1. Smaller size, and a size when none was set: no PATCH, warning copy, Apply sends one PATCH, Cancel reverts.
    1. One warning at a time; a longer or larger choice drops it.
    1. Save errors (500, 400, 403, network): only the size select reverts; Try again re-sends the same body without re-confirming; 403 refreshes the session and the card disappears.
    1. Offline disables both selects and Apply and Cancel; 401 is global.
    1. French parity for the new strings; lib/activitySentence.spec.ts covers the size-change sentence in English and French.

Visual (task visual:check)

The board draws the card in group "Settings: Retention" with the states 'Age only (no size limit)' and 'Size only (forever)'. In settings.md the states are bullets, not headings, so confirm on the first run whether the tool iterates them. Setups use the board's own sample data (5 GB, 3.2 GB) and the owner /v1/settings mock gets the widened body. Clock-sensitive states stay skip: 'clock-sensitive'. No exception beforehand.

Manual verification

  • Real SQLite file: activity_log_size_bytes grows as events are added and falls after a forced cleanup.
  • Forced cleanup with a small limit removes the oldest events and leaves the last 24 h.
  • Optional: SizeBytes on real MySQL and PostgreSQL.
  • Real PATCH as owner for each option, as admin 403, as owner with 2 GB 400.
  • Keyboard order: age select, size select, Apply, Cancel; focus returns to the triggering select after Cancel.
  • 1440 and 390 px, light and dark: the "or" divider and the 160 px or full-width size select as drawn in the board.