619 lines
44 KiB
Gherkin
619 lines
44 KiB
Gherkin
Feature: Recent activity card on Home and Retention card on Settings
|
|
|
|
As a RenewCMS user
|
|
I want Home to show my last five activity events and the owner to choose how long activity is kept on Settings
|
|
So that I see what happened without opening the Activity page, and the log does not grow without bound
|
|
|
|
Background:
|
|
Given the activity API (features/activity-log-backend.feature, activity-log-producers.feature, activity_screen.feature) and the settings retention fields already exist
|
|
And this slice adds one field to the settings API, activity_next_cleanup_at, and wires the SPA to the existing endpoints; there is no other Go change
|
|
And the Home card and the Settings "Retention" card are the two items activity_screen.feature left out of scope; the spec of record is docs/design/screens/home.md ("Recent activity") and docs/design/screens/settings.md ("Activity retention"), groups "Home" and "Settings: Retention" of RenewCMS Screens Back-office.dc.html
|
|
And both boards draw the existing cards as dashed placeholders, so neither card is pixel-compared and Vitest is the SPA coverage
|
|
And "owner" and "admin" are the full Home view; any other or absent session role is the regular-user view
|
|
And every new English string has a French entry in messages.ts
|
|
And GET /v1/settings (every verified user) and PATCH /v1/settings (owner only, behind RequireOwner) both answer with the JSON object {"version", "activity_retention_days", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}, snake_case, nothing else
|
|
And this supersedes the statement of features/settings_page.feature that the body is exactly {"version": "<value>"}; the version field and the verified-user access of that feature are unchanged
|
|
And activity_next_cleanup_at is an RFC 3339 timestamp with the server's offset, or null when activity_retention_days is 0 (Forever)
|
|
And otherwise it is the next 03:00 (audit.CleanupHour) in the server's time zone strictly after the server's current time
|
|
And the value is computed in the domain (audit.Settings) from the clock the use cases are built with, never in the handler and never in the SPA
|
|
And the cleanup it announces is the existing daily job, which never touches the last 24 hours (audit.KeepRecentHours)
|
|
And GetActivityRetentionUseCase and UpdateActivityRetentionUseCase both return the value, so a PATCH response carries the next cleanup of the new period (null after switching to Forever, a timestamp after leaving Forever)
|
|
And the existing validation, 400 with the text of apperr.BodyErrorMessage for any value outside 0, 30, 90, 365 or a malformed body, and 403 for a non-owner are unchanged
|
|
And the domain package stays free of GORM, HTTP and the clock source (the clock is passed in)
|
|
And on /home a card "Recent activity" (RecentActivityCard) sits in a 420 px column to the right of the existing cards from the lg breakpoint up, with a 16 px gap, in a grid (minmax(0,1fr) 420px, items aligned to the start) that spans the full content width, with no max-width, as the board frame draws it
|
|
And the greeting header and the update banner stay above the grid at full width
|
|
And below lg the card is stacked above the stat tiles and the recent article list, below the banner, with a 16 px gap
|
|
And the card is --ui-bg with a 1 px --ui-border and a header row (12px 16px padding, 1 px bottom border) holding the title (14 px semibold, --ui-text-highlighted) and, at the right, the link "View all" with an arrow-right icon (14 px; 44 px touch height below lg) to /activity
|
|
And the title is "Recent activity", and "Your recent activity" in the regular-user view
|
|
And on mount the card sends exactly one GET /v1/activity?size=5 with no other parameter and never an actor parameter; it never calls /summary or /export; the server returns own events only for a regular user
|
|
And no range is sent, so the server's default of 7 days applies and a site quiet for more than a week reads "No activity yet"
|
|
And the card loads independently of GET /v1/article/: neither waits for or blocks the other, and an error or empty card leaves the stat tiles, the recent article list, the articles' own empty and error states and "Connect your website" as they are
|
|
And each row (up to 5, in API order) has a 24 px avatar, the sentence clamped to two lines with the target name in semibold (composed by ActivitySentence exactly as on /activity, never stored text), then a muted 12 px line: "{actor name} · {time}" in the full view and only "{time}" in the regular-user view
|
|
And the avatar is the same as the Actor cell of /activity (initials for a user, key-round for an API key, cog for the system, user-round-x for an anonymous actor)
|
|
And the time of an event of today is "{n} min ago" or "{n} h ago" (lib/activityTime), an event of yesterday reads "Yesterday, 14:20" and an older one "3 Oct 2026, 14:20" (French "Hier, 14:20", "3 oct. 2026, 14:20"); the full local time with seconds is its title attribute
|
|
And a failed event shows a "Failed" badge at the right of its row; a successful event with security true shows a small shield icon (14 px, --ui-warning) and no badge; a failed security event shows the badge only
|
|
And rows are not interactive; only "View all" navigates
|
|
And while the request is in flight for longer than 150 ms the card body shows five skeleton rows (24 px circle, two bars) and aria-busy="true"; nothing is drawn in the body for the first 150 ms
|
|
And on a 500, any other non-401 status or a network failure the body is an inline role="alert" error with icon circle-x, title "Couldn't load recent activity", no description, and a "Try again" button that sends the same GET /v1/activity?size=5 again; a 401 is handled globally by useApi
|
|
And when total is 0 the body is the empty state: icon history, "No activity yet" and "Actions on your site will appear here." (regular user: "Your actions will appear here.")
|
|
And on /settings, for the owner only, a third card (RetentionCard) follows UpdatesCard and MediaCleanupCard and has the same width as its sibling cards; for an admin, a regular user or an absent role it is not rendered at all
|
|
And the card reads GET /v1/settings, the single call SettingsView already makes, whose typed response is widened with the four activity_* fields; the page still sends exactly one GET /v1/settings per navigation
|
|
And the header is "Retention" (14 px semibold) over "How long activity is kept." (12 px muted) with a status area at the right, aria-live="polite": "Saving…" (loader-circle) while a PATCH is in flight, "Saved" (check, --ui-success) for 2 s after it succeeds
|
|
And the body has the label "Keep activity for" over a USelectMenu (at most 260 px wide) with the items "30 days", "90 days", "365 days", "Forever" (values 30, 90, 365, 0) and a 12 px muted helper: "Older events are removed automatically." or, for Forever, "Nothing is removed automatically. The log grows over time."
|
|
And an info UAlert with icon lock is always shown: "Activity logs are append-only" / "Events can't be edited or deleted from the app. Only the retention period removes them."
|
|
And after a divider two read-only lines (120 px label then value; stacked on mobile) are shown in the user's local time: "Last cleanup" reads "6 Oct 2026, 03:00 · 142 events removed" ("1 event removed" for 1), or "Never" when activity_last_cleanup_at is null; "Next cleanup" reads "7 Oct 2026, 03:00" from activity_next_cleanup_at, or "Not scheduled" when it is null
|
|
And for Forever the last-cleanup line still shows the real last cleanup when one exists, and "Never" otherwise
|
|
And the periods are ordered 30 < 90 < 365 < Forever, and "shorter" means a lower rank than the saved period
|
|
And choosing a longer period, including Forever, sends PATCH /v1/settings with {"activity_retention_days": <value>} at once
|
|
And choosing a shorter period sends nothing and shows a warning UAlert with icon triangle-alert, title "Events older than {n} days will be removed", text "This happens at the next cleanup on {date}. It can't be undone." (the date is the saved settings' activity_next_cleanup_at, formatted like the line above) and the buttons "Apply {n} days" and "Cancel"
|
|
And a PATCH 200 returns the full settings, which refresh the select and the last and next lines
|
|
And any non-401 PATCH failure (400, 403, 500, network) shows an error UAlert with icon circle-x, title "Couldn't save the retention period", text "Your previous setting is still in place." and a "Try again" button, reverts the select to the saved value and leaves the lines unchanged; a 403 additionally refreshes the session, after which the card is gone
|
|
And "Try again" re-sends the failed value without asking for the shortening confirmation again
|
|
And while GET /v1/settings is loading for longer than 150 ms the card shows skeleton lines with aria-busy="true"
|
|
And when GET /v1/settings fails the card shows the Media cleanup card's alert, "Couldn't reach RenewCMS. Check your connection and try again." with "Try again", which re-runs the shared load
|
|
And while the browser is offline the select, "Apply" and "Cancel" are disabled; the page's offline banner copy is unchanged
|
|
|
|
Scenario Outline: Settings API, GET returns the next cleanup of the saved period
|
|
Given the saved retention is <days> days and the server time is <now> in a server zone of +02:00
|
|
When a verified user sends GET /v1/settings
|
|
Then the status is 200
|
|
And the body has "activity_next_cleanup_at" <next> and the other four fields
|
|
|
|
Examples:
|
|
| days | now | next |
|
|
| 90 | 2026-10-06T10:00:00+02:00 | "2026-10-07T03:00:00+02:00" |
|
|
| 90 | 2026-10-06T02:59:59+02:00 | "2026-10-06T03:00:00+02:00" |
|
|
| 90 | 2026-10-06T03:00:00+02:00 | "2026-10-07T03:00:00+02:00" |
|
|
| 30 | 2026-12-31T23:30:00+02:00 | "2027-01-01T03:00:00+02:00" |
|
|
| 365 | 2026-10-06T10:00:00+02:00 | "2026-10-07T03:00:00+02:00" |
|
|
| 0 | 2026-10-06T10:00:00+02:00 | null |
|
|
|
|
Scenario: Settings API, GET keeps the existing fields and adds exactly one
|
|
Given the saved retention is 90 days, the last cleanup is 2026-10-06T01:00:00Z with 142 events removed
|
|
When a verified user sends GET /v1/settings
|
|
Then the body is a JSON object with exactly the keys version, activity_retention_days, activity_last_cleanup_at, activity_last_cleanup_removed and activity_next_cleanup_at
|
|
|
|
Scenario: Settings API, GET is open to every verified user
|
|
Given a verified admin or editor sends GET /v1/settings
|
|
Then the status is 200 and the body includes activity_next_cleanup_at
|
|
|
|
Scenario Outline: Settings API, PATCH returns the next cleanup of the new period
|
|
Given the saved retention is <from> days and the user is the owner
|
|
When the owner sends PATCH /v1/settings with {"activity_retention_days": <to>}
|
|
Then the status is 200 and activity_retention_days is <to>
|
|
And activity_next_cleanup_at is <next>
|
|
|
|
Examples:
|
|
| from | to | next |
|
|
| 90 | 30 | the next 03:00 server time |
|
|
| 90 | 0 | null |
|
|
| 0 | 365 | the next 03:00 server time |
|
|
| 90 | 90 | the next 03:00 server time |
|
|
|
|
Scenario: Settings API, PATCH with the saved value changes nothing
|
|
Given the saved retention is 90 days
|
|
When the owner sends PATCH /v1/settings with {"activity_retention_days": 90}
|
|
Then the status is 200, nothing is saved and no audit event is recorded
|
|
|
|
Scenario: Settings API, PATCH by a non-owner is refused and changes nothing
|
|
Given a verified admin
|
|
When the admin sends PATCH /v1/settings with {"activity_retention_days": 30}
|
|
Then the status is 403 with the text "Only the owner can do this." and the saved retention is unchanged
|
|
|
|
Scenario Outline: Settings API, PATCH rejects invalid input
|
|
When the owner sends PATCH /v1/settings with <body>
|
|
Then the status is 400 with the text of apperr.BodyErrorMessage and nothing is saved
|
|
|
|
Examples:
|
|
| body |
|
|
| {"activity_retention_days": 7} |
|
|
| {"activity_retention_days": "30"} |
|
|
| {} |
|
|
| not json |
|
|
|
|
Scenario: Settings use cases compute the next cleanup from their clock
|
|
Given GetActivityRetentionUseCase and UpdateActivityRetentionUseCase are built with a fake settings repository and a fixed clock
|
|
Then GetActivityRetention returns Settings whose NextCleanupAt is the next 03:00 after the clock, or nil for Forever
|
|
And UpdateActivityRetention returns the new period with its own NextCleanupAt
|
|
And UpdateActivityRetention(7) returns an error satisfying errors.Is(err, audit.ErrInvalidRetention) and saves nothing
|
|
|
|
Scenario Outline: Home, owner or admin, the last five events with the actor name
|
|
Given the session role is "<role>" and GET /v1/activity?size=5 resolves 200 with 5 events, the first being Marta Alves publishing the article "Spring release" 2 minutes ago
|
|
When the user navigates to /home
|
|
Then the card title is "Recent activity" and "View all" links to /activity
|
|
And row 1 reads "Published the article Spring release" with "Spring release" in semibold, over "Marta Alves · 2 min ago", with a 24 px avatar "MA"
|
|
And exactly 5 rows are shown and GET /v1/activity was requested once with size=5 and no other parameter
|
|
|
|
Examples:
|
|
| role |
|
|
| owner |
|
|
| admin |
|
|
|
|
Scenario Outline: Home, regular user, own events only and no actor name
|
|
Given the session role is "<role>" and GET /v1/activity?size=5 resolves 200 with 3 own events
|
|
When the user navigates to /home
|
|
Then the card title is "Your recent activity"
|
|
And each row's meta line is only the time, for example "2 min ago"
|
|
And no actor parameter was sent
|
|
|
|
Examples:
|
|
| role |
|
|
| editor |
|
|
| absent |
|
|
|
|
Scenario Outline: Home rows, failure and security markers
|
|
Given an event with outcome "<outcome>" and security <security>
|
|
Then its row shows <marker>
|
|
|
|
Examples:
|
|
| outcome | security | marker |
|
|
| success | false | no badge and no shield |
|
|
| failed | false | a "Failed" badge |
|
|
| success | true | a shield icon and no badge |
|
|
| failed | true | a "Failed" badge and no shield |
|
|
|
|
Scenario Outline: Home rows, the time label follows the day
|
|
Given the current time is 2026-10-06 15:00 local and an event created <when>
|
|
Then the meta time reads "<label>"
|
|
|
|
Examples:
|
|
| when | label |
|
|
| 2026-10-06 14:58 | 2 min ago |
|
|
| 2026-10-06 12:00 | 3 h ago |
|
|
| 2026-10-05 14:20 | Yesterday, 14:20 |
|
|
| 2026-10-03 14:20 | 3 Oct 2026, 14:20 |
|
|
|
|
Scenario: Home rows, a long sentence is clamped to two lines
|
|
Given an event on an article whose title has 150 characters
|
|
Then the sentence is clamped to two lines with the title in semibold and the meta line stays on one line
|
|
|
|
Scenario: Home rows, an unknown action and a failed login
|
|
Given an event with action "article.archive" and an "auth.login_failed" event for "alex@example.com"
|
|
Then the first row shows "article.archive", the second "Failed login attempt for alex@example.com" with a "Failed" badge and the user-round-x avatar, and nothing throws
|
|
|
|
Scenario: Home rows are not interactive
|
|
Then a click on a row navigates nowhere and opens nothing
|
|
|
|
Scenario: Home card, skeleton rows only after 150 ms
|
|
Given GET /v1/activity?size=5 has not resolved
|
|
When the user navigates to /home
|
|
Then nothing is drawn in the card body for the first 150 ms
|
|
And after 150 ms five skeleton rows are shown and the card is aria-busy="true"
|
|
When the request resolves 200 with 5 events
|
|
Then the skeletons are replaced by 5 rows
|
|
And a response in under 150 ms never shows a skeleton
|
|
|
|
Scenario Outline: Home card, error shows a retryable alert that leaves the page alone
|
|
Given GET /v1/activity?size=5 <failure> and GET /v1/article/ resolves 200 with 6 articles
|
|
When the user navigates to /home
|
|
Then the card shows an alert "Couldn't load recent activity" with a "Try again" button
|
|
And the stat tiles "6" "articles", "4" "online", "2" "drafts", the recent article list and "Connect your website" are shown as in home.feature's Default state
|
|
When the user activates "Try again"
|
|
Then GET /v1/activity?size=5 is sent again and, on 200 with events, the rows replace the alert
|
|
|
|
Examples:
|
|
| failure |
|
|
| resolves with a 500 response |
|
|
| resolves with a 403 response |
|
|
| never reaches the server |
|
|
|
|
Scenario Outline: Home card, empty when there are no events yet
|
|
Given GET /v1/activity?size=5 resolves 200 with items [] and total 0 and the role is "<role>"
|
|
When the user navigates to /home
|
|
Then the card shows the history icon, "No activity yet" and "<text>"
|
|
And "View all" is still shown
|
|
|
|
Examples:
|
|
| role | text |
|
|
| owner | Actions on your site will appear here. |
|
|
| editor | Your actions will appear here. |
|
|
|
|
Scenario: Home card and article list are independent
|
|
Given GET /v1/article/ fails with a 500 and GET /v1/activity?size=5 resolves 200 with 5 events
|
|
Then the articles alert "Couldn't load your articles" and the 5 activity rows are both shown
|
|
And with GET /v1/article/ resolving [] and GET /v1/activity?size=5 failing, the articles' Empty state and the card's error are both shown
|
|
|
|
Scenario: Home layout is two columns from lg and stacked below
|
|
Given the viewport is lg or wider
|
|
Then the header and the update banner span the full content width and the grid below them spans the same full width with the article cards on the left and the activity card 420 px wide in the far-right column, 16 px apart, its top aligned with the top of the article cards
|
|
Given the viewport is below lg
|
|
Then the card sits above the stat tiles, below the banner, at full width
|
|
|
|
Scenario: Home card, "View all" opens the activity page
|
|
When the user activates "View all"
|
|
Then the router navigates to /activity
|
|
|
|
Scenario: Settings retention is hidden for everyone but the owner
|
|
Given GET /v1/settings resolves 200 with activity_retention_days 90
|
|
When an "admin" (or an "editor", or an absent role) navigates to /settings
|
|
Then no "Retention" card and no "Keep activity for" select is in the page
|
|
And the Version, Updates and Media cleanup cards are unchanged
|
|
And GET /v1/settings was requested once
|
|
|
|
Scenario: Settings retention, Default with 90 days
|
|
Given the role is "owner" and GET /v1/settings resolves 200 with activity_retention_days 90, activity_last_cleanup_at 6 Oct 2026 03:00 local, activity_last_cleanup_removed 142 and activity_next_cleanup_at 7 Oct 2026 03:00 local
|
|
When the owner navigates to /settings
|
|
Then the third card reads "Retention" and "How long activity is kept."
|
|
And the select shows "90 days" with the helper "Older events are removed automatically."
|
|
And the lock alert "Activity logs are append-only" is shown
|
|
And the lines read "Last cleanup" "6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup" "7 Oct 2026, 03:00"
|
|
And no "Saved" is shown, no PATCH was sent and GET /v1/settings was requested once
|
|
|
|
Scenario: Settings retention, before the first cleanup
|
|
Given activity_last_cleanup_at is null and activity_next_cleanup_at is set
|
|
Then "Last cleanup" reads "Never" and "Next cleanup" shows the date
|
|
|
|
Scenario: Settings retention, skeleton lines after 150 ms
|
|
Given GET /v1/settings has not resolved and the role is "owner"
|
|
When the owner navigates to /settings
|
|
Then nothing is drawn in the card body for the first 150 ms, then skeleton lines with aria-busy="true"
|
|
|
|
Scenario: Settings retention, the settings request fails
|
|
Given GET /v1/settings fails with a 500
|
|
Then the card shows "Couldn't reach RenewCMS. Check your connection and try again." with a "Try again" button that re-runs the shared GET /v1/settings
|
|
|
|
Scenario Outline: Settings retention, a longer period saves at once
|
|
Given the saved period is <from> and the owner chooses "<to>"
|
|
When PATCH /v1/settings resolves 200 with activity_retention_days <value> and the matching activity_next_cleanup_at
|
|
Then PATCH was sent once with {"activity_retention_days": <value>} and no warning alert is shown
|
|
And the status reads "Saving…" while in flight and "Saved" with a check afterwards, fading after 2 s
|
|
And the select shows "<to>" and the "Next cleanup" line shows the returned value
|
|
|
|
Examples:
|
|
| from | to | value |
|
|
| 90 days | 365 days | 365 |
|
|
| 30 days | 90 days | 90 |
|
|
| 90 days | Forever | 0 |
|
|
|
|
Scenario: Settings retention, Forever changes the helper and the lines
|
|
Given the saved period is 90 days and the owner chooses "Forever" and PATCH resolves 200 with activity_retention_days 0, activity_next_cleanup_at null and a last cleanup of 6 Oct 2026 03:00 with 142 removed
|
|
Then the helper reads "Nothing is removed automatically. The log grows over time."
|
|
And "Next cleanup" reads "Not scheduled" and "Last cleanup" still reads "6 Oct 2026, 03:00 · 142 events removed"
|
|
|
|
Scenario: Settings retention, Forever and never cleaned
|
|
Given GET /v1/settings resolves 200 with activity_retention_days 0, activity_last_cleanup_at null and activity_next_cleanup_at null
|
|
Then "Last cleanup" reads "Never" and "Next cleanup" reads "Not scheduled"
|
|
|
|
Scenario: Settings retention, shortening asks first
|
|
Given the saved period is 90 days and activity_next_cleanup_at is 7 Oct 2026 03:00 local
|
|
When the owner chooses "30 days"
|
|
Then no PATCH is sent and the select shows "30 days"
|
|
And a warning alert shows "Events older than 30 days will be removed" and "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone." with "Apply 30 days" and "Cancel"
|
|
When the owner activates "Apply 30 days" and PATCH resolves 200 with activity_retention_days 30
|
|
Then PATCH was sent once with {"activity_retention_days": 30}, the alert is gone and "Saved" shows
|
|
|
|
Scenario: Settings retention, shortening cancelled
|
|
Given the warning for 30 days is shown on a saved 90
|
|
When the owner activates "Cancel"
|
|
Then the alert is gone, the select reads "90 days" again and no PATCH was ever sent
|
|
|
|
Scenario Outline: Settings retention, shortening is relative to the saved period
|
|
Given the saved period is <saved>
|
|
When the owner chooses "<chosen>"
|
|
Then <outcome>
|
|
|
|
Examples:
|
|
| saved | chosen | outcome |
|
|
| Forever | 365 days | the warning "Events older than 365 days will be removed" shows |
|
|
| 365 days | 90 days | the warning "Events older than 90 days will be removed" shows |
|
|
| 90 days | 90 days | nothing happens and no request is sent |
|
|
| 30 days | Forever | PATCH is sent at once |
|
|
|
|
Scenario: Settings retention, choosing a longer period while the warning is open drops it
|
|
Given the warning for 30 days is shown on a saved 90
|
|
When the owner chooses "365 days"
|
|
Then the warning disappears and PATCH is sent once with 365
|
|
When the owner then chooses "30 days" and again "365 days" before the first request settles or after it
|
|
Then no stale warning is ever left on screen
|
|
|
|
Scenario Outline: Settings retention, save error reverts the control and Try again retries
|
|
Given the saved period is 90 days and the owner chooses "365 days"
|
|
When PATCH /v1/settings <failure>
|
|
Then an error alert shows "Couldn't save the retention period" and "Your previous setting is still in place." with "Try again"
|
|
And the select shows "90 days" again, the lines are unchanged and no "Saved" appears
|
|
When the owner activates "Try again" and PATCH resolves 200 with 365
|
|
Then PATCH was sent a second time with 365 and "Saved" replaces the alert
|
|
|
|
Examples:
|
|
| failure |
|
|
| resolves with a 500 response |
|
|
| resolves with a 400 response |
|
|
| resolves with a 403 response |
|
|
| never reaches the server |
|
|
|
|
Scenario: Settings retention, Try again after Apply does not ask again
|
|
Given the warning for 30 days is shown and the owner activates "Apply 30 days"
|
|
When PATCH resolves 500
|
|
Then the warning is gone, the error alert is shown and the select reads the saved period
|
|
When the owner activates "Try again" and PATCH resolves 200 with 30
|
|
Then PATCH was sent a second time with 30 and no warning reappears
|
|
|
|
Scenario: Settings retention, an owner demoted while the page is open
|
|
Given PATCH /v1/settings resolves 403 and GET /v1/auth/me then resolves with role "admin"
|
|
Then the error alert is shown and, after the session refresh, the Retention card is no longer rendered
|
|
|
|
Scenario: Settings retention, offline disables the controls
|
|
Given the owner is on /settings and the browser goes offline
|
|
Then the select, and "Apply" and "Cancel" when shown, are disabled and nothing is sent
|
|
And the page's offline banner keeps its existing copy
|
|
|
|
Scenario: Settings retention, a 401 is global
|
|
Given GET or PATCH /v1/settings resolves 401
|
|
Then useApi redirects to /login?redirect=/settings&reason=expired and no card alert is shown
|
|
|
|
Scenario: The French interface
|
|
Given the language is French
|
|
Then every string of both cards is French and the Home rows use the French sentence templates of activity_screen.feature
|
|
|
|
Scenario: Existing behaviour is unchanged
|
|
Then every scenario of home.feature, settings_page.feature (except its exact-body statement, superseded above) and the Media cleanup scenarios still holds with GET /v1/activity?size=5 mocked and the settings mock widened with the four activity_* fields
|
|
|
|
Rule: Retention size limit, an optional maximum size as an alternative to the age limit
|
|
|
|
Background:
|
|
Given this rule supersedes the statements above where they conflict: the settings body, the next-cleanup rule, the PATCH validation, the card copy and the shortening rule
|
|
And this extension changes Go code in the audit settings, the audit repositories, the settings and cleanup use cases and the settings handler
|
|
And GET /v1/settings and the PATCH response carry exactly {"version", "activity_retention_days", "activity_retention_max_gb", "activity_log_size_bytes", "activity_last_cleanup_at", "activity_last_cleanup_removed", "activity_next_cleanup_at"}, snake_case
|
|
And activity_next_cleanup_at is null only when activity_retention_days is 0 and activity_retention_max_gb is 0, otherwise it is the next 03:00 (audit.CleanupHour) in the server zone strictly after now, so Forever with a size limit has a schedule
|
|
And the PATCH body is a partial object with activity_retention_days in {0, 30, 90, 365} and/or activity_retention_max_gb in {0, 1, 5, 10, 25, 50}, absent keys are left unchanged, and an empty object, a null, a wrong type, any other value or malformed JSON answers 400 with apperr.BodyErrorMessage and saves nothing
|
|
And a non-owner PATCH answers 403 "Only the owner can do this."
|
|
And validation goes through validate tags and validator.Validate.Struct
|
|
And the size options are one list in the audit domain, audit.SizeOptions(), mirroring RetentionOptions()
|
|
And activity_log_size_bytes is the payload size of the activity events table, measured by audit.Repository.SizeBytes: the sum of the OCTET_LENGTH of the text columns action, actor_name, target_type, target_id, target_name, failure_reason, ip, user_agent, params and changes plus a fixed per-row overhead constant defined in the domain
|
|
And it is not the database file size and excludes indexes, free pages and other tables, and repositories never touch files
|
|
And the audit Repository gains SizeBytes() (int64, error) and DeleteOldest(limit int, before time.Time) (int, error); DeleteOldest removes the oldest events by created_at then id and only those older than before
|
|
And the cleanup runs the age step when days is greater than 0, then removes the oldest events in batches through DeleteOldest, calling SizeBytes between batches, until the log is at or under the limit or a batch removes 0
|
|
And the cutoff of every removal is the 24 hour guard (audit.KeepRecentHours); when the limit cannot be met because of the guard, the cleanup stops without error, records the run and tries again at the next 03:00
|
|
And Tick returns early only when days is 0 and max_gb is 0
|
|
And the removed count is the total of the age and size removals, in the last-cleanup fields and in the system.activity_cleanup params {"removed": n}
|
|
And system.activity_retention_changed is recorded once per PATCH that changes something, with params {"from","to"} when the age changes and "max_gb_from" and "max_gb_to" when the size changes, and no event when nothing changes
|
|
And lib/activitySentence.ts has an English and a French sentence for a size change
|
|
And GET /v1/settings computes the size on every call and a SizeBytes failure answers 500, like any use case error; every verified user receives the number, only the owner sees the card
|
|
And the SPA shows the size in GB (1 GB is 1024^3 bytes) with one decimal, or "Less than 0.1 GB" below 0.1 GB
|
|
And the header description is "How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it."
|
|
And the append-only alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
|
|
And below the age select an "or" divider is followed by the label "Keep at most" over a USelectMenu 160 px wide (full width on mobile) with the items "No size limit", "1 GB", "5 GB", "10 GB", "25 GB", "50 GB" (values 0, 1, 5, 10, 25, 50)
|
|
And the age helper is "Older events are removed automatically." without a size limit, "Events older than this are removed." with one, "Nothing is removed automatically. The log grows over time." for Forever without a size limit and "No age limit. Only the size limit applies." for Forever with one
|
|
And the size helper is "Optional. Logs can grow without a size cap." without a limit and "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB." with one, the figure being activity_log_size_bytes and never a hard-coded value
|
|
And the size rank is 1 < 5 < 10 < 25 < 50 < No size limit and a lower rank than the saved one is "smaller"
|
|
And choosing a smaller size, including a size when there was none, sends nothing and shows the warning "Events over {n} GB will be removed" with "This happens at the next cleanup on {date}. It can't be undone.", "Apply {n} GB" and "Cancel"; choosing a larger size or "No size limit" saves at once
|
|
And each select sends only its own key, both share one status area, one error alert and one pending warning, and a warning concerns one control at a time: a longer or larger choice on either control drops it and another shortening replaces it
|
|
And the visual states are "Default, 90 days or 5 GB", "Age only (no size limit)", "Size only (forever)", the shortening warning with a 5 GB limit, Forever and Save error
|
|
|
|
Scenario Outline: Settings API, GET reports the size limit and the measured size
|
|
Given the saved retention is <days> days with a size limit of <gb> GB and the activity table holds <bytes> bytes of payload
|
|
When a verified user sends GET /v1/settings at the server time 2026-10-06T10:00:00+02:00
|
|
Then the status is 200 and the body has exactly the seven keys
|
|
And "activity_retention_max_gb" is <gb> and "activity_log_size_bytes" is <bytes>
|
|
And "activity_next_cleanup_at" is <next>
|
|
|
|
Examples:
|
|
| days | gb | bytes | next |
|
|
| 90 | 0 | 0 | "2026-10-07T03:00:00+02:00" |
|
|
| 90 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
|
|
| 0 | 5 | 3435973837 | "2026-10-07T03:00:00+02:00" |
|
|
| 0 | 0 | 1024 | null |
|
|
|
|
Scenario: Settings API, a fresh install defaults to no size limit
|
|
Given no settings row exists
|
|
When a verified user sends GET /v1/settings
|
|
Then activity_retention_days is 90, activity_retention_max_gb is 0 and activity_log_size_bytes is 0
|
|
|
|
Scenario Outline: Settings API, PATCH saves a size limit, an age, or both
|
|
Given the saved retention is 90 days and no size limit and the user is the owner
|
|
When the owner sends PATCH /v1/settings with <body>
|
|
Then the status is 200 and the body has activity_retention_days <days>, activity_retention_max_gb <gb> and activity_next_cleanup_at <next>
|
|
And the repository holds <days> and <gb>
|
|
|
|
Examples:
|
|
| body | days | gb | next |
|
|
| {"activity_retention_max_gb": 5} | 90 | 5 | the next 03:00 server time |
|
|
| {"activity_retention_days": 0, "activity_retention_max_gb": 5} | 0 | 5 | the next 03:00 server time |
|
|
| {"activity_retention_days": 30} | 30 | 0 | the next 03:00 server time |
|
|
| {"activity_retention_days": 0} | 0 | 0 | null |
|
|
|
|
Scenario: Settings API, leaving the size limit while Forever clears the schedule
|
|
Given the saved retention is 0 days with a size limit of 5 GB
|
|
When the owner sends PATCH /v1/settings with {"activity_retention_max_gb": 0}
|
|
Then activity_next_cleanup_at is null
|
|
|
|
Scenario Outline: Settings API, PATCH rejects invalid size input
|
|
Given the saved retention is 90 days with a size limit of 5 GB
|
|
When the owner sends PATCH /v1/settings with <body>
|
|
Then the status is 400 with the text of apperr.BodyErrorMessage and nothing is saved
|
|
|
|
Examples:
|
|
| body |
|
|
| {"activity_retention_max_gb": 2} |
|
|
| {"activity_retention_max_gb": -1} |
|
|
| {"activity_retention_max_gb": 100} |
|
|
| {"activity_retention_max_gb": 5.5} |
|
|
| {"activity_retention_max_gb": "5"} |
|
|
| {"activity_retention_max_gb": null} |
|
|
| {"activity_retention_days": 30, "activity_retention_max_gb": 2} |
|
|
| {} |
|
|
|
|
Scenario: Settings API, a PATCH changing nothing is a no-op
|
|
Given the saved retention is 90 days and 5 GB
|
|
When the owner sends PATCH /v1/settings with {"activity_retention_days": 90, "activity_retention_max_gb": 5}
|
|
Then the status is 200, nothing is saved and no audit event is recorded
|
|
|
|
Scenario: Settings API, a non-owner cannot set the size limit
|
|
When an admin sends PATCH /v1/settings with {"activity_retention_max_gb": 5}
|
|
Then the status is 403 with "Only the owner can do this." and nothing is saved
|
|
|
|
Scenario: Settings API, a failing size measurement answers 500
|
|
Given SizeBytes fails
|
|
When a verified user sends GET /v1/settings
|
|
Then the status is 500 with the generic internal error text
|
|
|
|
Scenario Outline: Settings API, the change is audited
|
|
Given the saved retention is 90 days and no size limit
|
|
When the owner sends PATCH /v1/settings with <body>
|
|
Then one event "system.activity_retention_changed" exists with params <params>
|
|
|
|
Examples:
|
|
| body | params |
|
|
| {"activity_retention_days": 30} | {"from": 90, "to": 30} |
|
|
| {"activity_retention_max_gb": 5} | {"max_gb_from": 0, "max_gb_to": 5} |
|
|
| {"activity_retention_days": 30, "activity_retention_max_gb": 5} | {"from": 90, "to": 30, "max_gb_from": 0, "max_gb_to": 5} |
|
|
|
|
Scenario Outline: Cleanup, the size limit removes the oldest events
|
|
Given the retention is <days> days, the size limit is 5 GB and the log is 7 GB
|
|
And the oldest events are older than the guard
|
|
When the cleanup runs at 2026-10-06T03:00:00
|
|
Then events are removed oldest first, by created_at then id, until SizeBytes is at or under 5 GB
|
|
And the removed count is the total of the age removals and the size removals
|
|
And the last cleanup time is the clock's time
|
|
|
|
Examples:
|
|
| days |
|
|
| 90 |
|
|
| 0 |
|
|
|
|
Scenario: Cleanup, age first then size
|
|
Given the retention is 30 days, the size limit is 5 GB, 100 events are older than 30 days and the log still exceeds 5 GB after they go
|
|
When the cleanup runs
|
|
Then the 100 events go first, then the oldest remaining events until the log is at or under 5 GB
|
|
|
|
Scenario: Cleanup, the age limit alone is enough
|
|
Given the log is under the size limit after the age removal
|
|
When the cleanup runs
|
|
Then no size removal happens
|
|
|
|
Scenario: Cleanup, Forever with no size limit never runs
|
|
Given the retention is 0 days and no size limit
|
|
When Tick runs at 2026-10-06T03:00:00
|
|
Then ran is false and neither DeleteOlderThan nor DeleteOldest is called
|
|
|
|
Scenario: Cleanup, the last 24 hours are protected even when the size cannot be met
|
|
Given the size limit is 1 GB and the log is 4 GB, all of it from the last 24 hours
|
|
When the cleanup runs
|
|
Then nothing is removed, no error is returned, the last cleanup records 0 removed and the next tick tries again
|
|
|
|
Scenario: Cleanup, a failing size removal is retried
|
|
Given DeleteOldest fails
|
|
When Tick runs
|
|
Then the error is returned and logged, the last cleanup fields are unchanged and the next tick runs again
|
|
|
|
Scenario: Cleanup, a size removal records one system event
|
|
Given the age step removed 10 events and the size step removed 40
|
|
When the cleanup runs
|
|
Then one "system.activity_cleanup" event exists with params {"removed": 50}
|
|
|
|
Scenario: Persistence measures the table payload portably
|
|
Given a real in-memory SQLite database and events with known text lengths, one with a multi-byte name
|
|
Then SizeBytes equals the sum of the byte lengths of the text columns plus the fixed per-row overhead
|
|
And it is 0 for an empty table
|
|
And deleting events lowers it
|
|
|
|
Scenario: Persistence deletes the oldest events up to a limit and a guard
|
|
Given 10 events, 3 of them newer than the guard
|
|
When DeleteOldest runs with limit 5
|
|
Then the 5 oldest events are removed
|
|
When DeleteOldest runs with limit 20
|
|
Then only the 2 remaining events older than the guard are removed and the 3 newest stay
|
|
|
|
Scenario: Settings retention, Default with 90 days or 5 GB
|
|
Given the role is "owner" and GET /v1/settings resolves 200 with activity_retention_days 90, activity_retention_max_gb 5 and activity_log_size_bytes 3435973837
|
|
When the owner navigates to /settings
|
|
Then the age select shows "90 days" with the helper "Events older than this are removed."
|
|
And an "or" divider and "Keep at most" show "5 GB" with the helper "When logs pass 5 GB, the oldest events are removed. Currently 3.2 GB."
|
|
And the lock alert reads "Events can't be edited or deleted from the app. Only the retention limits remove them."
|
|
And "Next cleanup" shows the date from activity_next_cleanup_at
|
|
|
|
Scenario: Settings retention, Age only (no size limit)
|
|
Given activity_retention_max_gb is 0
|
|
When the owner navigates to /settings
|
|
Then the size select reads "No size limit" with the helper "Optional. Logs can grow without a size cap." and the age helper is "Older events are removed automatically."
|
|
|
|
Scenario: Settings retention, Size only (forever)
|
|
Given activity_retention_days is 0, activity_retention_max_gb is 5 and activity_next_cleanup_at is set
|
|
When the owner navigates to /settings
|
|
Then the age helper reads "No age limit. Only the size limit applies." and "Next cleanup" shows the date, not "Not scheduled"
|
|
|
|
Scenario: Settings retention, Forever with no size limit
|
|
Given activity_retention_days is 0, activity_retention_max_gb is 0 and activity_next_cleanup_at is null
|
|
When the owner navigates to /settings
|
|
Then the age helper reads "Nothing is removed automatically. The log grows over time." and "Next cleanup" reads "Not scheduled"
|
|
|
|
Scenario Outline: Settings retention, the measured size is formatted
|
|
Given the size limit is 5 GB and activity_log_size_bytes is <bytes>
|
|
When the owner navigates to /settings
|
|
Then the size helper ends with "<shown>"
|
|
|
|
Examples:
|
|
| bytes | shown |
|
|
| 3435973837 | Currently 3.2 GB. |
|
|
| 0 | Currently Less than 0.1 GB. |
|
|
| 53687091 | Currently Less than 0.1 GB. |
|
|
| 5368709120 | Currently 5.0 GB. |
|
|
|
|
Scenario Outline: Settings retention, a larger size or no limit saves at once
|
|
Given the saved size is <from>
|
|
When the owner chooses "<to>"
|
|
Then PATCH is sent once with {"activity_retention_max_gb": <value>}, never the age key, and no warning shows
|
|
And the shared status reads "Saving…" then "Saved"
|
|
|
|
Examples:
|
|
| from | to | value |
|
|
| 5 GB | 10 GB | 10 |
|
|
| 5 GB | No size limit | 0 |
|
|
| 1 GB | 50 GB | 50 |
|
|
|
|
Scenario Outline: Settings retention, a smaller size asks first
|
|
Given the saved size is <from>
|
|
When the owner chooses "<to>"
|
|
Then no PATCH is sent and the size select shows "<to>"
|
|
And a warning alert shows "Events over <n> GB will be removed" and "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone." with "Apply <n> GB" and "Cancel"
|
|
|
|
Examples:
|
|
| from | to | n |
|
|
| 10 GB | 5 GB | 5 |
|
|
| No size limit | 5 GB | 5 |
|
|
| 50 GB | 1 GB | 1 |
|
|
|
|
Scenario: Settings retention, Apply and Cancel for a smaller size
|
|
Given the warning for 5 GB is shown on a saved 10 GB
|
|
When the owner activates "Apply 5 GB" and PATCH resolves 200 with activity_retention_max_gb 5
|
|
Then PATCH was sent once with {"activity_retention_max_gb": 5} and "Saved" shows
|
|
Given the warning is shown again
|
|
When the owner activates "Cancel"
|
|
Then the size select reads the saved value and no PATCH was sent
|
|
|
|
Scenario: Settings retention, only one warning at a time
|
|
Given the age warning for 30 days is open
|
|
When the owner chooses "5 GB" as a smaller size
|
|
Then the age warning is replaced by the size warning and the age select reverts to the saved value
|
|
When the owner chooses a longer age or a larger size
|
|
Then the warning is dropped and PATCH is sent once for that control
|
|
|
|
Scenario: Settings retention, switching age to Forever with a size limit
|
|
Given the saved period is 90 days and 5 GB
|
|
When the owner chooses "Forever"
|
|
Then PATCH is sent with {"activity_retention_days": 0} and the age helper becomes "No age limit. Only the size limit applies."
|
|
And "Next cleanup" still shows the returned date
|
|
|
|
Scenario Outline: Settings retention, a size save error reverts only the size select
|
|
Given the saved size is 5 GB and the owner chooses "10 GB"
|
|
When PATCH /v1/settings <failure>
|
|
Then the error alert "Couldn't save the retention period" with "Your previous setting is still in place." and "Try again" shows
|
|
And the size select reads "5 GB" again and the age select is unchanged
|
|
When the owner activates "Try again" and PATCH resolves 200
|
|
Then PATCH was sent a second time with {"activity_retention_max_gb": 10} and "Saved" replaces the alert
|
|
|
|
Examples:
|
|
| failure |
|
|
| resolves with a 500 response |
|
|
| resolves with a 400 response |
|
|
| resolves with a 403 response |
|
|
| never reaches the server |
|
|
|
|
Scenario: Settings retention, offline disables the size select too
|
|
Given the owner is on /settings and the browser goes offline
|
|
Then both selects, "Apply" and "Cancel" are disabled
|
|
|
|
Scenario: The French interface covers the size strings
|
|
Given the language is French
|
|
Then every new string has a French entry in messages.ts, and the Activity sentence for a size change is French
|