feat: recent activity card on Home and Retention card on Settings
This commit is contained in:
1 parent
35db442cd3
commit
43469ef08d
33 files changed
+2621
-77
No files matched your search
@@ -320,8 +320,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
|
||||
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
|
||||
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo),
|
||||
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder),
|
||||
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo, time.Now),
|
||||
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder, time.Now),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ type filterQuery struct {
|
||||
type listQuery struct {
|
||||
filterQuery
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"oneof=25 50 100"`
|
||||
Size int `validate:"oneof=5 25 50 100"`
|
||||
}
|
||||
|
||||
type actorResponse struct {
|
||||
|
||||
@@ -26,6 +26,7 @@ type settingsResponse struct {
|
||||
ActivityRetentionDays int `json:"activity_retention_days"`
|
||||
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
|
||||
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
|
||||
ActivityNextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
|
||||
}
|
||||
|
||||
func NewHandler(getSettings *useCases.GetSettingsUseCase, getRetention *useCases.GetActivityRetentionUseCase, updateRetention *useCases.UpdateActivityRetentionUseCase) *Handler {
|
||||
@@ -70,6 +71,7 @@ func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
|
||||
ActivityRetentionDays: retention.RetentionDays,
|
||||
ActivityLastCleanupAt: retention.LastCleanupAt,
|
||||
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
|
||||
ActivityNextCleanupAt: retention.NextCleanupAt,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
|
||||
@@ -273,6 +273,7 @@ Feature: The back-office keeps an append-only activity log that owners can read
|
||||
| range=30d | events of the last 30 days |
|
||||
| range=custom&from=2026-09-01&to=2026-09-30 | events from 1 to 30 September 2026 UTC |
|
||||
| page=2&size=50 | the second page of 50 |
|
||||
| size=5 | the first page of 5 |
|
||||
|
||||
Scenario: Omitting range means 7 days
|
||||
Given events 2 days old and 10 days old
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
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 card") and docs/design/screens/settings.md ("Activity retention"), groups "Home card" and "Settings retention" of RenewCMS Screens Activity.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
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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):
|
||||
|
||||
14. Hidden for admin, editor and absent role; GET /v1/settings called once.
|
||||
15. 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.
|
||||
16. 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".
|
||||
17. Skeleton after 150 ms with aria-busy; load error alert and "Try again" re-runs the shared GET.
|
||||
18. 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".
|
||||
19. 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.
|
||||
20. 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.
|
||||
21. Offline (mock useOnline false): select, Apply and Cancel disabled; nothing sent; page offline banner text unchanged.
|
||||
22. 401 handled globally: no local alert.
|
||||
23. French key parity: every new string has a messages.ts entry.
|
||||
24. Keyboard: the select opens with Enter or Space, arrows and Enter choose, Escape closes; Apply then Cancel reachable in order after the select.
|
||||
25. 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.
|
||||
@@ -26,6 +26,7 @@ type Settings struct {
|
||||
RetentionDays int `json:"activity_retention_days"`
|
||||
LastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
|
||||
LastCleanupRemoved int `json:"activity_last_cleanup_removed"`
|
||||
NextCleanupAt *time.Time `json:"activity_next_cleanup_at"`
|
||||
}
|
||||
|
||||
func DefaultSettings() Settings {
|
||||
@@ -40,6 +41,19 @@ func (s Settings) CleanupDue(now time.Time) bool {
|
||||
return s.LastCleanupAt == nil || s.LastCleanupAt.Before(todayRun)
|
||||
}
|
||||
|
||||
func (s Settings) WithNextCleanup(now time.Time) Settings {
|
||||
s.NextCleanupAt = nil
|
||||
if s.RetentionDays == ForeverRetentionDays {
|
||||
return s
|
||||
}
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = time.Date(now.Year(), now.Month(), now.Day()+1, CleanupHour, 0, 0, 0, now.Location())
|
||||
}
|
||||
s.NextCleanupAt = &next
|
||||
return s
|
||||
}
|
||||
|
||||
func ClampCutoff(now time.Time, cutoff time.Time) time.Time {
|
||||
newest := now.Add(-KeepRecentHours * time.Hour)
|
||||
if cutoff.After(newest) {
|
||||
|
||||
@@ -3,27 +3,34 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetActivityRetentionUseCase struct {
|
||||
settings audit.SettingsRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository) *GetActivityRetentionUseCase {
|
||||
return &GetActivityRetentionUseCase{settings}
|
||||
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository, now func() time.Time) *GetActivityRetentionUseCase {
|
||||
return &GetActivityRetentionUseCase{settings, now}
|
||||
}
|
||||
|
||||
func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) {
|
||||
return g.settings.Get()
|
||||
current, err := g.settings.Get()
|
||||
if err != nil {
|
||||
return audit.Settings{}, err
|
||||
}
|
||||
return current.WithNextCleanup(g.now()), nil
|
||||
}
|
||||
|
||||
type UpdateActivityRetentionUseCase struct {
|
||||
settings audit.SettingsRepository
|
||||
recorder audit.Recorder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder) *UpdateActivityRetentionUseCase {
|
||||
return &UpdateActivityRetentionUseCase{settings, recorder}
|
||||
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder, now func() time.Time) *UpdateActivityRetentionUseCase {
|
||||
return &UpdateActivityRetentionUseCase{settings, recorder, now}
|
||||
}
|
||||
|
||||
func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) {
|
||||
@@ -35,7 +42,7 @@ func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Con
|
||||
return audit.Settings{}, err
|
||||
}
|
||||
if current.RetentionDays == days {
|
||||
return current, nil
|
||||
return current.WithNextCleanup(u.now()), nil
|
||||
}
|
||||
if err := u.settings.SaveRetention(days); err != nil {
|
||||
return audit.Settings{}, err
|
||||
@@ -46,5 +53,5 @@ func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Con
|
||||
u.recorder.Record(ctx, event)
|
||||
|
||||
current.RetentionDays = days
|
||||
return current, nil
|
||||
return current.WithNextCleanup(u.now()), nil
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const (
|
||||
securityOnly = "true"
|
||||
)
|
||||
|
||||
var activityPageSizes = []int{25, 50, 100}
|
||||
var activityPageSizes = []int{5, 25, 50, 100}
|
||||
|
||||
type ActivityQuery struct {
|
||||
Q string
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ActivityAvatar from '@/components/ActivityAvatar.vue'
|
||||
import ActivityBadge from '@/components/ActivityBadge.vue'
|
||||
import { initials, type ActivityActor } from '@/lib/activity'
|
||||
import { type ActivityActor } from '@/lib/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
actor: ActivityActor
|
||||
@@ -12,39 +13,11 @@ const props = defineProps<{
|
||||
const displayName = computed(() =>
|
||||
props.actor.type === 'anonymous' ? t('Not signed in') : props.actor.name,
|
||||
)
|
||||
|
||||
const iconName = computed(() => {
|
||||
switch (props.actor.type) {
|
||||
case 'api_key':
|
||||
return 'i-lucide-key-round'
|
||||
case 'system':
|
||||
return 'i-lucide-cog'
|
||||
default:
|
||||
return 'i-lucide-user-round-x'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex min-w-0 items-center gap-2" data-testid="activity-actor">
|
||||
<UAvatar
|
||||
v-if="actor.type === 'user'"
|
||||
aria-hidden="true"
|
||||
:text="initials(actor.name)"
|
||||
:ui="{
|
||||
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
|
||||
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
|
||||
}"
|
||||
/>
|
||||
<UAvatar
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
:icon="iconName"
|
||||
:ui="{
|
||||
root: 'size-6 shrink-0 border border-accented bg-elevated',
|
||||
icon: 'size-3.5 text-toned',
|
||||
}"
|
||||
/>
|
||||
<ActivityAvatar :actor="actor" />
|
||||
<span class="min-w-0 truncate font-medium text-highlighted">{{ displayName }}</span>
|
||||
<ActivityBadge v-if="actor.type === 'api_key'" tone="neutral" :label="t('API key')" />
|
||||
<ActivityBadge v-else-if="actor.type === 'system'" tone="neutral" :label="t('System')" />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { initials, type ActivityActor } from '@/lib/activity'
|
||||
|
||||
const props = defineProps<{
|
||||
actor: ActivityActor
|
||||
}>()
|
||||
|
||||
const iconName = computed(() => {
|
||||
switch (props.actor.type) {
|
||||
case 'api_key':
|
||||
return 'i-lucide-key-round'
|
||||
case 'system':
|
||||
return 'i-lucide-cog'
|
||||
default:
|
||||
return 'i-lucide-user-round-x'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAvatar
|
||||
v-if="actor.type === 'user'"
|
||||
aria-hidden="true"
|
||||
:text="initials(actor.name)"
|
||||
:ui="{
|
||||
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
|
||||
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
|
||||
}"
|
||||
/>
|
||||
<UAvatar
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
:icon="iconName"
|
||||
:ui="{
|
||||
root: 'size-6 shrink-0 border border-accented bg-elevated',
|
||||
icon: 'size-3.5 text-toned',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import ActivityAvatar from '@/components/ActivityAvatar.vue'
|
||||
import ActivityBadge from '@/components/ActivityBadge.vue'
|
||||
import ActivitySentence from '@/components/ActivitySentence.vue'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { fullLocalTime, recentWhenLabel } from '@/lib/activityTime'
|
||||
|
||||
const SKELETON_ROWS = 5
|
||||
const SKELETON_SENTENCE_WIDTHS = ['w-17/20', 'w-7/10', 'w-9/10', 'w-3/5', 'w-4/5']
|
||||
|
||||
defineProps<{
|
||||
items: ActivityItem[]
|
||||
loading: boolean
|
||||
showSkeleton: boolean
|
||||
error: boolean
|
||||
own: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
function metaLine(item: ActivityItem, own: boolean) {
|
||||
const when = recentWhenLabel(item.created_at)
|
||||
if (own) return when
|
||||
const name = item.actor.type === 'anonymous' ? t('Not signed in') : item.actor.name
|
||||
return `${name} · ${when}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
data-testid="recent-activity-card"
|
||||
:aria-busy="loading && showSkeleton ? 'true' : undefined"
|
||||
:ui="{
|
||||
root: 'min-w-0 rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
|
||||
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
|
||||
body: 'flex flex-col px-4 pt-1 pb-1.5 sm:px-4 sm:pt-1 sm:pb-1.5',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="flex-1 text-base font-semibold text-highlighted">
|
||||
{{ own ? t('Your recent activity') : t('Recent activity') }}
|
||||
</h2>
|
||||
<RouterLink
|
||||
to="/activity"
|
||||
class="flex items-center gap-1 font-medium text-highlighted underline underline-offset-3 max-lg:min-h-11 lg:min-h-5"
|
||||
>
|
||||
{{ t('View all') }}
|
||||
<UIcon name="i-lucide-arrow-right" class="size-3.5" />
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template v-if="loading">
|
||||
<template v-if="showSkeleton">
|
||||
<div
|
||||
v-for="n in SKELETON_ROWS"
|
||||
:key="n"
|
||||
data-testid="recent-activity-skeleton"
|
||||
class="flex gap-2.5 py-2.5"
|
||||
:class="n > 1 ? 'border-t border-muted' : ''"
|
||||
>
|
||||
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
|
||||
<div class="flex flex-1 flex-col gap-1.5">
|
||||
<USkeleton
|
||||
class="h-3 rounded-none bg-accented"
|
||||
:class="SKELETON_SENTENCE_WIDTHS[n - 1]"
|
||||
/>
|
||||
<USkeleton class="h-2.5 w-7/20 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<UAlert
|
||||
v-else-if="error"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Couldn\'t load recent activity')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
|
||||
@click="$emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
data-testid="recent-activity-empty"
|
||||
class="flex flex-col items-center gap-1.5 py-6 text-center"
|
||||
>
|
||||
<UIcon name="i-lucide-history" class="size-5 text-muted" />
|
||||
<span class="font-semibold text-highlighted">{{ t('No activity yet') }}</span>
|
||||
<span class="max-w-60 text-muted">
|
||||
{{
|
||||
own
|
||||
? t('Your actions will appear here.')
|
||||
: t('Actions on your site will appear here.')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul v-else class="flex flex-col">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
data-testid="recent-activity-row"
|
||||
class="flex items-start gap-2.5 py-2.5"
|
||||
:class="index > 0 ? 'border-t border-muted' : ''"
|
||||
>
|
||||
<ActivityAvatar :actor="item.actor" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
class="line-clamp-2 text-default wrap-anywhere"
|
||||
data-testid="recent-activity-sentence"
|
||||
>
|
||||
<ActivitySentence :item="item" />
|
||||
</span>
|
||||
<span
|
||||
data-testid="recent-activity-meta"
|
||||
:title="fullLocalTime(item.created_at)"
|
||||
class="truncate text-xs/5 text-muted"
|
||||
>
|
||||
{{ metaLine(item, own) }}
|
||||
</span>
|
||||
</div>
|
||||
<ActivityBadge v-if="item.outcome === 'failed'" tone="fail" :label="t('Failed')" />
|
||||
<UIcon
|
||||
v-else-if="item.security"
|
||||
name="i-lucide-shield-alert"
|
||||
data-testid="recent-activity-security"
|
||||
class="size-3.5 shrink-0 text-warning"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import ActivityFeedCard from '@/components/ActivityFeedCard.vue'
|
||||
import { useActivity } from '@/composables/useActivity'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const RECENT_SIZE = 5
|
||||
|
||||
const session = useSessionStore()
|
||||
const { listRecentActivity } = useActivity()
|
||||
|
||||
const items = ref<ActivityItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const showSkeleton = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const own = computed(() => session.session?.role !== 'owner' && session.session?.role !== 'admin')
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
showSkeleton.value = false
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
try {
|
||||
const page = await listRecentActivity(RECENT_SIZE)
|
||||
items.value = page.items.slice(0, RECENT_SIZE)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActivityFeedCard
|
||||
:items="items"
|
||||
:loading="loading"
|
||||
:show-skeleton="showSkeleton"
|
||||
:error="error"
|
||||
:own="own"
|
||||
@retry="load"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
|
||||
import {
|
||||
SAVED_VISIBLE_MS,
|
||||
helperCopy,
|
||||
isShorter,
|
||||
lastCleanupLine,
|
||||
nextCleanupLine,
|
||||
retentionOptions,
|
||||
shorteningDescription,
|
||||
} from '@/lib/activityRetention'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const SKELETON_BAR = 'rounded-none bg-accented'
|
||||
const OUTLINE_BUTTON =
|
||||
'h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
|
||||
const HTTP_UNAUTHORIZED = 401
|
||||
const HTTP_FORBIDDEN = 403
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved'
|
||||
|
||||
const props = defineProps<{
|
||||
settings: WorkspaceSettings | null
|
||||
loadState: 'loading' | 'ready' | 'error'
|
||||
showSkeleton: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [settings: WorkspaceSettings]
|
||||
reload: []
|
||||
}>()
|
||||
|
||||
const session = useSessionStore()
|
||||
const { updateRetention } = useSettings()
|
||||
const online = useOnline()
|
||||
|
||||
const selected = ref(props.settings?.activity_retention_days ?? 0)
|
||||
const shortening = ref<number | null>(null)
|
||||
const failedValue = ref<number | null>(null)
|
||||
const saveStatus = ref<SaveStatus>('idle')
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingSaves = 0
|
||||
|
||||
const offline = computed(() => !online.value)
|
||||
const skeletonVisible = computed(() => props.loadState === 'loading' && props.showSkeleton)
|
||||
const savedDays = computed(() => props.settings?.activity_retention_days ?? 0)
|
||||
const options = computed(() => retentionOptions())
|
||||
|
||||
watch(savedDays, (days) => {
|
||||
if (shortening.value === null && pendingSaves === 0) {
|
||||
selected.value = days
|
||||
}
|
||||
})
|
||||
|
||||
function clearSavedTimer() {
|
||||
if (savedTimer !== null) {
|
||||
clearTimeout(savedTimer)
|
||||
savedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function finishSaving(succeeded: boolean) {
|
||||
if (!succeeded) {
|
||||
saveStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
saveStatus.value = 'saved'
|
||||
savedTimer = setTimeout(() => {
|
||||
saveStatus.value = 'idle'
|
||||
}, SAVED_VISIBLE_MS)
|
||||
}
|
||||
|
||||
async function save(days: number) {
|
||||
pendingSaves += 1
|
||||
clearSavedTimer()
|
||||
failedValue.value = null
|
||||
selected.value = days
|
||||
saveStatus.value = 'saving'
|
||||
let succeeded = false
|
||||
try {
|
||||
const updated = await updateRetention(days)
|
||||
emit('saved', updated)
|
||||
selected.value = updated.activity_retention_days
|
||||
succeeded = true
|
||||
} catch (error) {
|
||||
selected.value = savedDays.value
|
||||
const status = error instanceof ApiError ? error.status : 0
|
||||
if (status !== HTTP_UNAUTHORIZED) {
|
||||
failedValue.value = days
|
||||
}
|
||||
if (status === HTTP_FORBIDDEN) {
|
||||
session.refresh()
|
||||
}
|
||||
} finally {
|
||||
pendingSaves -= 1
|
||||
if (pendingSaves === 0) {
|
||||
finishSaving(succeeded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function choose(value: number) {
|
||||
if (offline.value) {
|
||||
return
|
||||
}
|
||||
shortening.value = null
|
||||
failedValue.value = null
|
||||
if (value === savedDays.value) {
|
||||
selected.value = value
|
||||
return
|
||||
}
|
||||
if (isShorter(value, savedDays.value)) {
|
||||
selected.value = value
|
||||
shortening.value = value
|
||||
return
|
||||
}
|
||||
save(value)
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const days = shortening.value
|
||||
shortening.value = null
|
||||
if (days !== null) {
|
||||
save(days)
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
shortening.value = null
|
||||
selected.value = savedDays.value
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (failedValue.value !== null) {
|
||||
save(failedValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearSavedTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
as="section"
|
||||
aria-labelledby="retention-h"
|
||||
data-testid="retention-card"
|
||||
:aria-busy="skeletonVisible ? 'true' : undefined"
|
||||
:ui="{
|
||||
root: 'rounded-none border border-default bg-default shadow-none ring-0 divide-y-0',
|
||||
header: 'flex items-center gap-2 border-b border-default px-4 py-3 sm:px-4 sm:py-3',
|
||||
body: 'flex flex-col gap-4 p-4 sm:p-4',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<h2 id="retention-h" class="text-base font-semibold text-highlighted">
|
||||
{{ t('Retention') }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted">{{ t('How long activity is kept.') }}</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
data-testid="retention-save-status"
|
||||
class="flex h-6 shrink-0 items-center"
|
||||
>
|
||||
<span
|
||||
v-if="saveStatus === 'saving'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
|
||||
{{ t('Saving…') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="saveStatus === 'saved'"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-success"
|
||||
>
|
||||
<UIcon name="i-lucide-check" class="size-3.5" />
|
||||
{{ t('Saved') }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="skeletonVisible">
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-30']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-8 w-full max-w-65']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3 w-3/5']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-9/10']" />
|
||||
<USkeleton :class="[SKELETON_BAR, 'h-3.5 w-7/10']" />
|
||||
</template>
|
||||
|
||||
<UAlert
|
||||
v-else-if="loadState === 'error'"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="t(UNREACHABLE_COPY)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
:class="OUTLINE_BUTTON"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('reload')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<template v-else-if="loadState === 'ready' && settings">
|
||||
<UAlert
|
||||
v-if="failedValue !== null"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="t('Couldn\'t save the retention period')"
|
||||
:description="t('Your previous setting is still in place.')"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:class="OUTLINE_BUTTON"
|
||||
@click="retry"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div class="flex max-w-65 flex-col gap-1.5">
|
||||
<span id="retention-label" class="font-medium text-highlighted">
|
||||
{{ t('Keep activity for') }}
|
||||
</span>
|
||||
<USelectMenu
|
||||
:model-value="selected"
|
||||
:items="options"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
:disabled="offline"
|
||||
aria-labelledby="retention-label"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0 md:h-8',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'min-w-48',
|
||||
}"
|
||||
@update:model-value="choose($event as number)"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ helperCopy(selected) }}</span>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="shortening !== null"
|
||||
role="alert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
orientation="vertical"
|
||||
:close="false"
|
||||
icon="i-lucide-triangle-alert"
|
||||
:title="t('Events older than {0} days will be removed', [shortening])"
|
||||
:description="shorteningDescription(settings)"
|
||||
:ui="{ actions: 'mt-2' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="offline"
|
||||
:class="OUTLINE_BUTTON"
|
||||
@click="apply"
|
||||
>
|
||||
{{ t('Apply {0} days', [shortening]) }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="offline"
|
||||
:class="OUTLINE_BUTTON"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('Cancel') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UAlert
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('Activity logs are append-only')"
|
||||
:description="
|
||||
t(
|
||||
'Events can\'t be edited or deleted from the app. Only the retention period removes them.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<dl class="flex flex-col gap-1.5">
|
||||
<div class="flex flex-col md:flex-row md:gap-3">
|
||||
<dt class="shrink-0 text-muted md:w-30">{{ t('Last cleanup') }}</dt>
|
||||
<dd data-testid="retention-last" class="text-highlighted">
|
||||
{{ lastCleanupLine(settings) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row md:gap-3">
|
||||
<dt class="shrink-0 text-muted md:w-30">{{ t('Next cleanup') }}</dt>
|
||||
<dd data-testid="retention-next" class="text-highlighted">
|
||||
{{ nextCleanupLine(settings) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -31,6 +31,10 @@ export function useActivity() {
|
||||
return request<ActivityPage>(`/v1/activity?${listQuery(filters, page, size, withActor)}`)
|
||||
}
|
||||
|
||||
function listRecentActivity(size: number): Promise<ActivityPage> {
|
||||
return request<ActivityPage>(`/v1/activity?size=${size}`)
|
||||
}
|
||||
|
||||
function getSummary(filters: ActivityFilters): Promise<ActivitySummary> {
|
||||
return request<ActivitySummary>(`/v1/activity/summary?${summaryQuery(filters)}`)
|
||||
}
|
||||
@@ -54,7 +58,7 @@ export function useActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
return { listActivity, getSummary, exportActivity, listPeople }
|
||||
return { listActivity, listRecentActivity, getSummary, exportActivity, listPeople }
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
|
||||
@@ -2,14 +2,29 @@ import { useApi } from '@/composables/useApi'
|
||||
|
||||
export interface WorkspaceSettings {
|
||||
version: string
|
||||
activity_retention_days: number
|
||||
activity_last_cleanup_at: string | null
|
||||
activity_last_cleanup_removed: number
|
||||
activity_next_cleanup_at: string | null
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = '/v1/settings/'
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' }
|
||||
|
||||
export function useSettings() {
|
||||
const { request } = useApi()
|
||||
|
||||
function getSettings(): Promise<WorkspaceSettings> {
|
||||
return request<WorkspaceSettings>('/v1/settings/')
|
||||
return request<WorkspaceSettings>(SETTINGS_PATH)
|
||||
}
|
||||
|
||||
return { getSettings }
|
||||
function updateRetention(days: number): Promise<WorkspaceSettings> {
|
||||
return request<WorkspaceSettings>(SETTINGS_PATH, {
|
||||
method: 'PATCH',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ activity_retention_days: days }),
|
||||
})
|
||||
}
|
||||
|
||||
return { getSettings, updateRetention }
|
||||
}
|
||||
@@ -1183,6 +1183,38 @@ export const messages = {
|
||||
'Failed to check for updates': 'Échec de la recherche de mises à jour',
|
||||
'Failed to download the update {0}': 'Échec du téléchargement de la mise à jour {0}',
|
||||
'Failed to restart': 'Échec du redémarrage',
|
||||
'Recent activity': 'Activité récente',
|
||||
'Your recent activity': 'Votre activité récente',
|
||||
'View all': 'Tout voir',
|
||||
"Couldn't load recent activity": 'Impossible de charger l’activité récente',
|
||||
'Actions on your site will appear here.': 'Les actions sur votre site apparaîtront ici.',
|
||||
'Your actions will appear here.': 'Vos actions apparaîtront ici.',
|
||||
Retention: 'Conservation',
|
||||
'How long activity is kept.': 'Durée de conservation de l’activité.',
|
||||
'Keep activity for': 'Conserver l’activité pendant',
|
||||
'90 days': '90 jours',
|
||||
'365 days': '365 jours',
|
||||
'Older events are removed automatically.':
|
||||
'Les anciens événements sont supprimés automatiquement.',
|
||||
'Nothing is removed automatically. The log grows over time.':
|
||||
'Rien n’est supprimé automatiquement. Le journal grossit avec le temps.',
|
||||
'Activity logs are append-only': 'Le journal d’activité est en ajout seul',
|
||||
"Events can't be edited or deleted from the app. Only the retention period removes them.":
|
||||
'Les événements ne peuvent pas être modifiés ni supprimés depuis l’application. Seule la durée de conservation les supprime.',
|
||||
'Last cleanup': 'Dernier nettoyage',
|
||||
'Next cleanup': 'Prochain nettoyage',
|
||||
'Not scheduled': 'Non planifié',
|
||||
'{0} event removed': '{0} événement supprimé',
|
||||
'{0} events removed': '{0} événements supprimés',
|
||||
'Events older than {0} days will be removed':
|
||||
'Les événements de plus de {0} jours seront supprimés',
|
||||
"This happens at the next cleanup on {0}. It can't be undone.":
|
||||
'Cela se produira au prochain nettoyage, le {0}. Cette action est irréversible.',
|
||||
"Older events will be removed at the next cleanup. It can't be undone.":
|
||||
'Les anciens événements seront supprimés au prochain nettoyage. Cette action est irréversible.',
|
||||
'Apply {0} days': 'Appliquer {0} jours',
|
||||
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
|
||||
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import type { WorkspaceSettings } from '@/composables/useSettings'
|
||||
import { recentWhenLabel } from '../activityTime'
|
||||
import {
|
||||
helperCopy,
|
||||
isShorter,
|
||||
lastCleanupLine,
|
||||
nextCleanupLine,
|
||||
shorteningDescription,
|
||||
} from '../activityRetention'
|
||||
|
||||
const NOW = new Date(2026, 9, 6, 15, 0)
|
||||
|
||||
function at(day: number, hour: number, minute: number) {
|
||||
return new Date(2026, 9, day, hour, minute).toISOString()
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings {
|
||||
return {
|
||||
version: 'v2.2.0-beta',
|
||||
activity_retention_days: 90,
|
||||
activity_last_cleanup_at: at(6, 3, 0),
|
||||
activity_last_cleanup_removed: 142,
|
||||
activity_next_cleanup_at: at(7, 3, 0),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('recentWhenLabel follows the day', () => {
|
||||
it.each([
|
||||
[at(6, 14, 58), '2 min ago'],
|
||||
[at(6, 12, 0), '3 h ago'],
|
||||
[at(5, 14, 20), 'Yesterday, 14:20'],
|
||||
[at(3, 14, 20), '3 Oct 2026, 14:20'],
|
||||
])('%s reads %s', (iso, label) => {
|
||||
expect(recentWhenLabel(iso, NOW)).toBe(label)
|
||||
})
|
||||
|
||||
it('reads French labels in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(recentWhenLabel(at(5, 14, 20), NOW)).toBe('Hier, 14:20')
|
||||
expect(recentWhenLabel(at(3, 14, 20), NOW)).toBe('3 oct. 2026, 14:20')
|
||||
})
|
||||
})
|
||||
|
||||
describe('retention periods', () => {
|
||||
it('ranks 30 < 90 < 365 < Forever', () => {
|
||||
expect(isShorter(30, 90)).toBe(true)
|
||||
expect(isShorter(365, 0)).toBe(true)
|
||||
expect(isShorter(0, 365)).toBe(false)
|
||||
expect(isShorter(90, 90)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retention lines', () => {
|
||||
it('reads the last cleanup with its count', () => {
|
||||
expect(lastCleanupLine(settings())).toBe('6 Oct 2026, 03:00 · 142 events removed')
|
||||
expect(lastCleanupLine(settings({ activity_last_cleanup_removed: 1 }))).toBe(
|
||||
'6 Oct 2026, 03:00 · 1 event removed',
|
||||
)
|
||||
})
|
||||
|
||||
it('reads Never before the first cleanup and Not scheduled for Forever', () => {
|
||||
expect(lastCleanupLine(settings({ activity_last_cleanup_at: null }))).toBe('Never')
|
||||
expect(nextCleanupLine(settings({ activity_next_cleanup_at: null }))).toBe('Not scheduled')
|
||||
expect(nextCleanupLine(settings())).toBe('7 Oct 2026, 03:00')
|
||||
})
|
||||
|
||||
it('words the helper and the warning', () => {
|
||||
expect(helperCopy(90)).toBe('Older events are removed automatically.')
|
||||
expect(helperCopy(0)).toBe('Nothing is removed automatically. The log grows over time.')
|
||||
expect(shorteningDescription(settings())).toBe(
|
||||
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { WorkspaceSettings } from '@/composables/useSettings'
|
||||
import { localDayTime } from '@/lib/activityTime'
|
||||
|
||||
export const FOREVER_DAYS = 0
|
||||
export const SAVED_VISIBLE_MS = 2000
|
||||
|
||||
const RANKED_PERIODS = [30, 90, 365, FOREVER_DAYS]
|
||||
|
||||
export function retentionOptions() {
|
||||
return RANKED_PERIODS.map((value) => ({ value, label: periodLabel(value) }))
|
||||
}
|
||||
|
||||
export function periodLabel(days: number) {
|
||||
switch (days) {
|
||||
case 30:
|
||||
return t('30 days')
|
||||
case 90:
|
||||
return t('90 days')
|
||||
case 365:
|
||||
return t('365 days')
|
||||
default:
|
||||
return t('Forever')
|
||||
}
|
||||
}
|
||||
|
||||
export function periodRank(days: number) {
|
||||
return RANKED_PERIODS.indexOf(days)
|
||||
}
|
||||
|
||||
export function isShorter(candidate: number, saved: number) {
|
||||
return periodRank(candidate) < periodRank(saved)
|
||||
}
|
||||
|
||||
export function helperCopy(days: number) {
|
||||
return days === FOREVER_DAYS
|
||||
? t('Nothing is removed automatically. The log grows over time.')
|
||||
: t('Older events are removed automatically.')
|
||||
}
|
||||
|
||||
export function lastCleanupLine(settings: WorkspaceSettings) {
|
||||
if (!settings.activity_last_cleanup_at) {
|
||||
return t('Never')
|
||||
}
|
||||
const removed = settings.activity_last_cleanup_removed
|
||||
const count = t(removed === 1 ? '{0} event removed' : '{0} events removed', [removed])
|
||||
return `${localDayTime(settings.activity_last_cleanup_at)} · ${count}`
|
||||
}
|
||||
|
||||
export function nextCleanupLine(settings: WorkspaceSettings) {
|
||||
return settings.activity_next_cleanup_at
|
||||
? localDayTime(settings.activity_next_cleanup_at)
|
||||
: t('Not scheduled')
|
||||
}
|
||||
|
||||
export function shorteningDescription(settings: WorkspaceSettings) {
|
||||
return settings.activity_next_cleanup_at
|
||||
? t("This happens at the next cleanup on {0}. It can't be undone.", [
|
||||
localDayTime(settings.activity_next_cleanup_at),
|
||||
])
|
||||
: t("Older events will be removed at the next cleanup. It can't be undone.")
|
||||
}
|
||||
@@ -64,6 +64,21 @@ export function whenLabel(iso: string, now: Date = new Date()) {
|
||||
return t('{0} h ago', [Math.floor(elapsed / HOUR_MS)])
|
||||
}
|
||||
|
||||
export function recentWhenLabel(iso: string, now: Date = new Date()) {
|
||||
const date = new Date(iso)
|
||||
const key = localDayKey(date)
|
||||
if (key === localDayKey(now)) return whenLabel(iso, now)
|
||||
const today = startOfDay(now)
|
||||
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
|
||||
if (key === localDayKey(yesterday)) return `${t('Yesterday')}, ${clock(date, false, false)}`
|
||||
return localDayTime(iso)
|
||||
}
|
||||
|
||||
export function localDayTime(iso: string) {
|
||||
const date = new Date(iso)
|
||||
return `${formatAbsoluteDay(date)}, ${clock(date, false, false)}`
|
||||
}
|
||||
|
||||
export function fullLocalTime(iso: string) {
|
||||
const date = new Date(iso)
|
||||
return `${formatAbsoluteDay(date)}, ${clock(date, true, false)}`
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import RecentActivityCard from '@/components/RecentActivityCard.vue'
|
||||
import UpdateBanner from '@/components/UpdateBanner.vue'
|
||||
import { useArticles, type Article } from '@/composables/useArticles'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
@@ -180,7 +181,7 @@ onMounted(loadArticles)
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex max-w-244 flex-col gap-5 lg:gap-7">
|
||||
<div class="flex flex-col gap-5 lg:gap-7">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-1">
|
||||
<h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
|
||||
@@ -205,7 +206,14 @@ onMounted(loadArticles)
|
||||
@dismiss="dismissBanner"
|
||||
/>
|
||||
|
||||
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_--spacing(105)] lg:items-start"
|
||||
>
|
||||
<RecentActivityCard class="lg:order-2" />
|
||||
<div class="flex min-w-0 flex-col gap-5 lg:order-1 lg:gap-7">
|
||||
<template
|
||||
v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'"
|
||||
>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="tile in statTiles"
|
||||
@@ -231,7 +239,10 @@ onMounted(loadArticles)
|
||||
<h2 class="text-lg font-semibold text-highlighted">
|
||||
{{ t('Recently updated') }}
|
||||
</h2>
|
||||
<RouterLink to="/article" class="text-toned underline underline-offset-3">
|
||||
<RouterLink
|
||||
to="/article"
|
||||
class="text-toned underline underline-offset-3"
|
||||
>
|
||||
{{ t('All articles') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -245,17 +256,25 @@ onMounted(loadArticles)
|
||||
:key="n"
|
||||
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
|
||||
>
|
||||
<USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" />
|
||||
<USkeleton
|
||||
class="h-3 max-w-[60%] flex-1 rounded-none bg-accented"
|
||||
/>
|
||||
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
<ul v-else data-testid="recent-list" class="border border-default bg-default">
|
||||
<ul
|
||||
v-else
|
||||
data-testid="recent-list"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<li v-for="article in recentArticles" :key="article.id">
|
||||
<RouterLink
|
||||
:to="`/article/${article.id}/edit`"
|
||||
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-highlighted">
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate font-medium text-highlighted"
|
||||
>
|
||||
{{ article.title }}
|
||||
</span>
|
||||
<ArticleStatusBadge :online="article.is_online" />
|
||||
@@ -274,7 +293,9 @@ onMounted(loadArticles)
|
||||
icon="i-lucide-file-text"
|
||||
:title="t('Write your first article')"
|
||||
:description="
|
||||
t('It starts as a draft, so nothing appears on your site until you publish it.')
|
||||
t(
|
||||
'It starts as a draft, so nothing appears on your site until you publish it.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 rounded-none border border-default px-6 py-12 ring-0 sm:px-6 sm:py-12 lg:px-6 lg:py-12',
|
||||
@@ -348,6 +369,8 @@ onMounted(loadArticles)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
|
||||
</DashboardShell>
|
||||
|
||||
@@ -7,10 +7,11 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
|
||||
import RestartOverlay from '@/components/RestartOverlay.vue'
|
||||
import RetentionCard from '@/components/RetentionCard.vue'
|
||||
import UpdatesCard from '@/components/UpdatesCard.vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { useSettings } from '@/composables/useSettings'
|
||||
import { useSettings, type WorkspaceSettings } from '@/composables/useSettings'
|
||||
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import { reloadPage } from '@/lib/reloadPage'
|
||||
import { displayVersion, isCheckDue } from '@/lib/updateFormat'
|
||||
@@ -52,7 +53,9 @@ const retry = useRetryAfter()
|
||||
|
||||
const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const version = ref('')
|
||||
const settings = ref<WorkspaceSettings | null>(null)
|
||||
const version = computed(() => settings.value?.version ?? '')
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const showSkeleton = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -76,8 +79,7 @@ async function loadSettings() {
|
||||
}, SKELETON_DELAY_MS)
|
||||
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
version.value = settings.version
|
||||
settings.value = await getSettings()
|
||||
loadState.value = 'ready'
|
||||
} catch (error) {
|
||||
if (!isHandledGlobally(error)) {
|
||||
@@ -458,6 +460,15 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<MediaCleanupCard />
|
||||
|
||||
<RetentionCard
|
||||
v-if="isOwner"
|
||||
:settings="settings"
|
||||
:load-state="loadState"
|
||||
:show-skeleton="showSkeleton"
|
||||
@saved="settings = $event"
|
||||
@reload="loadSettings"
|
||||
/>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,384 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import HomeView from '../HomeView.vue'
|
||||
import { anonymous, makeItem, target } from './activityFixtures'
|
||||
|
||||
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
|
||||
const ARTICLES_ROUTE = 'GET /v1/article/?format=html'
|
||||
|
||||
type Handler = () => Response | Promise<Response>
|
||||
|
||||
function response(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function page(items: ActivityItem[], total = items.length) {
|
||||
return response(200, { items, total, page: 1, size: 5 })
|
||||
}
|
||||
|
||||
function recentItems(count: number) {
|
||||
const created = new Date(Date.now() - 30_000).toISOString()
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
makeItem({ id: index + 1, created_at: created }),
|
||||
)
|
||||
}
|
||||
|
||||
const articles = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'First article',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-06T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
let wrapper: Awaited<ReturnType<typeof mountHome>> | null = null
|
||||
let calls: string[] = []
|
||||
|
||||
async function mountHome(role: 'owner' | 'admin' | undefined, handlers: Record<string, Handler>) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', role }
|
||||
session.status = 'ready'
|
||||
calls = []
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? 'GET'} ${url}`
|
||||
calls.push(key)
|
||||
const handler = handlers[key]
|
||||
if (!handler) {
|
||||
return Promise.reject(new TypeError(`Unexpected fetch call: ${key}`))
|
||||
}
|
||||
return Promise.resolve(handler())
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const mounted = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
wrapper = mounted
|
||||
await flushPromises()
|
||||
return mounted
|
||||
}
|
||||
|
||||
function card() {
|
||||
return wrapper!.find('[data-testid="recent-activity-card"]')
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return wrapper!.findAll('[data-testid="recent-activity-row"]')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('Home, owner or admin, the last five events with the actor name', () => {
|
||||
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
|
||||
await mountHome(role, {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().find('h2').text()).toBe('Recent activity')
|
||||
const viewAll = card().find('a[href="/activity"]')
|
||||
expect(viewAll.text()).toBe('View all')
|
||||
expect(rows()).toHaveLength(5)
|
||||
const first = rows()[0]!
|
||||
expect(first.find('[data-testid="activity-sentence"]').text()).toBe(
|
||||
'Published the article Spring release',
|
||||
)
|
||||
expect(first.find('[data-testid="activity-sentence"] .font-semibold').text()).toBe(
|
||||
'Spring release',
|
||||
)
|
||||
expect(first.find('[data-testid="recent-activity-meta"]').text()).toMatch(
|
||||
/^Marta Alves · .+/,
|
||||
)
|
||||
expect(first.text()).toContain('MA')
|
||||
expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(1)
|
||||
expect(calls.some((call) => call.includes('actor'))).toBe(false)
|
||||
expect(calls.some((call) => call.includes('summary') || call.includes('export'))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home, regular user, own events only and no actor name', () => {
|
||||
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
|
||||
await mountHome(role as undefined, {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().find('h2').text()).toBe('Your recent activity')
|
||||
expect(rows()).toHaveLength(3)
|
||||
const meta = rows()[0]!.find('[data-testid="recent-activity-meta"]').text()
|
||||
expect(meta).not.toContain('Marta')
|
||||
expect(meta).not.toContain('·')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home rows, failure and security markers', () => {
|
||||
it('shows the badge and the shield as the outcome and security dictate', async () => {
|
||||
const items = [
|
||||
makeItem({ id: 1 }),
|
||||
makeItem({ id: 2, outcome: 'failed' }),
|
||||
makeItem({ id: 3, security: true }),
|
||||
makeItem({ id: 4, outcome: 'failed', security: true }),
|
||||
]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const marks = rows().map((row) => ({
|
||||
badge: row.text().includes('Failed'),
|
||||
shield: row.find('[data-testid="recent-activity-security"]').exists(),
|
||||
}))
|
||||
expect(marks).toEqual([
|
||||
{ badge: false, shield: false },
|
||||
{ badge: true, shield: false },
|
||||
{ badge: false, shield: true },
|
||||
{ badge: true, shield: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home rows, unknown action, failed login and not interactive', () => {
|
||||
it('renders an unknown action and a failed login without throwing', async () => {
|
||||
const items = [
|
||||
makeItem({ id: 1, action: 'article.archive', target: null }),
|
||||
makeItem({
|
||||
id: 2,
|
||||
action: 'auth.login_failed',
|
||||
category: 'auth',
|
||||
actor: anonymous('alex@example.com'),
|
||||
target: null,
|
||||
outcome: 'failed',
|
||||
}),
|
||||
]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(rows()[0]!.text()).toContain('article.archive')
|
||||
expect(rows()[1]!.text()).toContain('Failed login attempt for alex@example.com')
|
||||
expect(rows()[1]!.text()).toContain('Failed')
|
||||
})
|
||||
|
||||
it('has no button or link inside a row and keeps the route on a click', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const row = rows()[0]!
|
||||
await row.trigger('click')
|
||||
|
||||
expect(row.find('a, button').exists()).toBe(false)
|
||||
expect(router.currentRoute.value.path).toBe('/home')
|
||||
})
|
||||
|
||||
it('clamps a long sentence to two lines', async () => {
|
||||
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(rows()[0]!.find('.line-clamp-2').exists()).toBe(true)
|
||||
expect(rows()[0]!.find('[data-testid="recent-activity-meta"]').classes()).toContain(
|
||||
'truncate',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, skeleton rows only after 150 ms', () => {
|
||||
it('draws nothing, then five skeletons, then the rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
let resolve: (() => void) | null = null
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () =>
|
||||
new Promise((done) => {
|
||||
resolve = () => done(page(recentItems(5)))
|
||||
}),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().attributes('aria-busy')).toBeUndefined()
|
||||
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(card().attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(5)
|
||||
|
||||
resolve?.()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
|
||||
expect(card().attributes('aria-busy')).toBeUndefined()
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('never shows a skeleton for a fast response', async () => {
|
||||
vi.useFakeTimers()
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
|
||||
expect(wrapper!.findAll('[data-testid="recent-activity-skeleton"]')).toHaveLength(0)
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, error shows a retryable alert that leaves the page alone', () => {
|
||||
it.each([
|
||||
['500', () => response(500)],
|
||||
['403', () => response(403)],
|
||||
['network', () => Promise.reject(new TypeError('offline'))],
|
||||
])('shows the alert on %s and recovers on Try again', async (_name, failure) => {
|
||||
let attempt = 0
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => {
|
||||
attempt += 1
|
||||
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
|
||||
},
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const alert = card().find('[role="alert"]')
|
||||
expect(alert.text()).toContain("Couldn't load recent activity")
|
||||
expect(wrapper!.find('[data-testid="stat-tile"]').exists()).toBe(true)
|
||||
expect(wrapper!.text()).toContain('Connect your website')
|
||||
|
||||
const retry = alert.findAll('button').find((button) => button.text() === 'Try again')
|
||||
await retry!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((call) => call === ACTIVITY_ROUTE)).toHaveLength(2)
|
||||
expect(card().find('[role="alert"]').exists()).toBe(false)
|
||||
expect(rows()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, empty when there are no events yet', () => {
|
||||
it.each([
|
||||
['owner' as const, 'Actions on your site will appear here.'],
|
||||
[undefined, 'Your actions will appear here.'],
|
||||
])('says so for %s', async (role, text) => {
|
||||
await mountHome(role as 'owner', {
|
||||
[ACTIVITY_ROUTE]: () => page([], 0),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const empty = card().find('[data-testid="recent-activity-empty"]')
|
||||
expect(empty.text()).toContain('No activity yet')
|
||||
expect(empty.text()).toContain(text)
|
||||
expect(card().find('a[href="/activity"]').text()).toBe('View all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card and article list are independent', () => {
|
||||
it('shows the articles alert and the activity rows together', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(500),
|
||||
})
|
||||
|
||||
expect(wrapper!.text()).toContain("Couldn't load your articles")
|
||||
expect(rows()).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('shows the articles empty state and the card error together', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => response(500),
|
||||
[ARTICLES_ROUTE]: () => response(200, []),
|
||||
})
|
||||
|
||||
expect(wrapper!.text()).toContain('Write your first article')
|
||||
expect(card().text()).toContain("Couldn't load recent activity")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home layout is two columns from lg and stacked below', () => {
|
||||
it('uses the lg two-column grid with the 420 px column and no max-width cap', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const grid = card().element.parentElement!
|
||||
expect(grid.className).toContain('lg:grid-cols-[minmax(0,1fr)_--spacing(105)]')
|
||||
expect(grid.className).toContain('lg:items-start')
|
||||
expect(grid.className).toContain('gap-4')
|
||||
const capped = [grid, grid.parentElement!].some((element) =>
|
||||
/(^|\s)max-w-/.test(element.className),
|
||||
)
|
||||
expect(capped).toBe(false)
|
||||
expect(card().classes()).toContain('lg:order-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, the request URL', () => {
|
||||
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
|
||||
expect(activityCalls).toEqual(['GET /v1/activity?size=5'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, "View all" opens the activity page', () => {
|
||||
it('navigates to /activity', async () => {
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
await card().find('a[href="/activity"]').trigger('click')
|
||||
|
||||
expect(push).toHaveBeenCalledWith('/activity')
|
||||
push.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Home card, the French interface', () => {
|
||||
it('titles and words the card in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page([], 0),
|
||||
[ARTICLES_ROUTE]: () => response(200, articles),
|
||||
})
|
||||
|
||||
expect(card().find('h2').text()).toBe('Activité récente')
|
||||
expect(card().text()).toContain('Tout voir')
|
||||
expect(card().text()).toContain('Aucune activité pour le moment')
|
||||
})
|
||||
})
|
||||
@@ -107,7 +107,11 @@ async function mountHome(handlers: Record<string, () => Response | Promise<Respo
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||
global.fetch = fetchRouter({
|
||||
'GET /v1/activity?size=5': () =>
|
||||
Promise.resolve(mockResponse(200, { items: [], total: 0, page: 1, size: 5 })),
|
||||
...handlers,
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/home')
|
||||
const wrapper = mount(HomeView, {
|
||||
attachTo: document.body,
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
|
||||
import router from '@/router'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { WorkspaceSettings } from '@/composables/useSettings'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const SETTINGS_PATH = '/v1/settings/'
|
||||
const SAVED_VISIBLE_MS = 2000
|
||||
|
||||
function at(day: number, hour: number) {
|
||||
return new Date(2026, 9, day, hour, 0).toISOString()
|
||||
}
|
||||
|
||||
function settings(overrides: Partial<WorkspaceSettings> = {}): WorkspaceSettings {
|
||||
return {
|
||||
version: 'v2.2.0-beta',
|
||||
activity_retention_days: 90,
|
||||
activity_last_cleanup_at: at(6, 3),
|
||||
activity_last_cleanup_removed: 142,
|
||||
activity_next_cleanup_at: at(7, 3),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
let getMeMock: ReturnType<typeof vi.fn>
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
role: 'owner' | 'admin' | 'editor' | undefined,
|
||||
load: () => Promise<unknown>,
|
||||
patch: (body: unknown) => Promise<unknown> = () => Promise.resolve(settings()),
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||||
if (path === SETTINGS_PATH && init?.method === 'PATCH') {
|
||||
return patch(JSON.parse(init.body as string))
|
||||
}
|
||||
if (path === SETTINGS_PATH) {
|
||||
return load()
|
||||
}
|
||||
if (path === PURGE_SETTINGS_PATH) {
|
||||
return Promise.resolve(DEFAULT_PURGE_SETTINGS)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function retention() {
|
||||
return document.body.querySelector('[data-testid="retention-card"]')
|
||||
}
|
||||
|
||||
function text() {
|
||||
return retention()?.textContent?.replace(/\s+/g, ' ') ?? ''
|
||||
}
|
||||
|
||||
function line(testId: string) {
|
||||
return document.body.querySelector(`[data-testid="${testId}"]`)?.textContent?.trim()
|
||||
}
|
||||
|
||||
function patches() {
|
||||
return requestMock.mock.calls.filter(([, init]) => init?.method === 'PATCH')
|
||||
}
|
||||
|
||||
function settingsGets() {
|
||||
return requestMock.mock.calls.filter(
|
||||
([path, init]) => path === SETTINGS_PATH && init?.method !== 'PATCH',
|
||||
)
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)
|
||||
}
|
||||
|
||||
async function choose(days: number) {
|
||||
wrapper!.findComponent(USelectMenu).vm.$emit('update:modelValue', days)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function selectedLabel() {
|
||||
const select = wrapper!.findComponent(USelectMenu)
|
||||
const value = select.props('modelValue')
|
||||
return (select.props('items') as { value: number; label: string }[]).find(
|
||||
(item) => item.value === value,
|
||||
)?.label
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Settings retention is hidden for everyone but the owner', () => {
|
||||
it.each([['admin' as const], ['editor' as const], [undefined]])(
|
||||
'renders nothing for %s',
|
||||
async (role) => {
|
||||
await mountPage(role, () => Promise.resolve(settings()))
|
||||
|
||||
expect(retention()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Keep activity for')
|
||||
expect(document.body.textContent).toContain('Media cleanup')
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Settings retention, Default with 90 days', () => {
|
||||
it('shows the header, the select, the helper, the lock alert and the lines', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
|
||||
expect(text()).toContain('Retention')
|
||||
expect(text()).toContain('How long activity is kept.')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(text()).toContain('Older events are removed automatically.')
|
||||
expect(text()).toContain('Activity logs are append-only')
|
||||
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
expect(text()).not.toContain('Saved')
|
||||
expect(patches()).toHaveLength(0)
|
||||
expect(settingsGets()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads Never before the first cleanup', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(settings({ activity_last_cleanup_at: null })),
|
||||
)
|
||||
|
||||
expect(line('retention-last')).toBe('Never')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
})
|
||||
|
||||
it('reads Never and Not scheduled for Forever when never cleaned', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({
|
||||
activity_retention_days: 0,
|
||||
activity_last_cleanup_at: null,
|
||||
activity_next_cleanup_at: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(selectedLabel()).toBe('Forever')
|
||||
expect(line('retention-last')).toBe('Never')
|
||||
expect(line('retention-next')).toBe('Not scheduled')
|
||||
expect(text()).toContain('Nothing is removed automatically. The log grows over time.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, skeleton and load failure', () => {
|
||||
it('draws nothing in the body for 150 ms, then skeleton lines', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage('owner', () => pending.promise)
|
||||
|
||||
expect(retention()?.getAttribute('aria-busy')).toBeNull()
|
||||
expect(text()).not.toContain('Keep activity for')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
|
||||
expect(retention()?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(text()).not.toContain('Keep activity for')
|
||||
})
|
||||
|
||||
it('shows the shared alert and re-runs the shared load', async () => {
|
||||
let attempt = 0
|
||||
await mountPage('owner', () => {
|
||||
attempt += 1
|
||||
return attempt === 1 ? Promise.reject(new ApiError(500)) : Promise.resolve(settings())
|
||||
})
|
||||
|
||||
expect(text()).toContain("Couldn't reach RenewCMS. Check your connection and try again.")
|
||||
|
||||
const inCard = Array.from(retention()!.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Try again',
|
||||
)
|
||||
inCard!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(settingsGets()).toHaveLength(2)
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, a longer period saves at once', () => {
|
||||
it.each([
|
||||
[90, 365],
|
||||
[30, 90],
|
||||
[90, 0],
|
||||
])('saves %s to %s with one PATCH and shows Saved for 2 s', async (from, to) => {
|
||||
vi.useFakeTimers()
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings({ activity_retention_days: from })),
|
||||
() => pending.promise,
|
||||
)
|
||||
|
||||
await choose(to)
|
||||
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: to }))
|
||||
expect(text()).toContain('Saving…')
|
||||
expect(text()).not.toContain('Events older than')
|
||||
|
||||
pending.resolve(
|
||||
settings({
|
||||
activity_retention_days: to,
|
||||
activity_next_cleanup_at: to === 0 ? null : at(8, 3),
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).toContain('Saved')
|
||||
expect(line('retention-next')).toBe(to === 0 ? 'Not scheduled' : '8 Oct 2026, 03:00')
|
||||
expect(line('retention-last')).toBe('6 Oct 2026, 03:00 · 142 events removed')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(SAVED_VISIBLE_MS)
|
||||
|
||||
expect(text()).not.toContain('Saved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, shortening asks first', () => {
|
||||
it('warns without sending, then applies', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.resolve(settings({ activity_retention_days: 30 })),
|
||||
)
|
||||
|
||||
await choose(30)
|
||||
|
||||
expect(patches()).toHaveLength(0)
|
||||
expect(selectedLabel()).toBe('30 days')
|
||||
expect(text()).toContain('Events older than 30 days will be removed')
|
||||
expect(text()).toContain(
|
||||
"This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.",
|
||||
)
|
||||
|
||||
button('Apply 30 days')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 30 }))
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(text()).toContain('Saved')
|
||||
})
|
||||
|
||||
it('cancels back to the saved period without any request', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
await choose(30)
|
||||
|
||||
button('Cancel')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(patches()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, 365, true],
|
||||
[365, 90, true],
|
||||
[90, 90, false],
|
||||
[30, 0, false],
|
||||
])('saved %s then chosen %s warns: %s', async (saved, chosen, warns) => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings({ activity_retention_days: saved })),
|
||||
() => Promise.resolve(settings({ activity_retention_days: chosen })),
|
||||
)
|
||||
|
||||
await choose(chosen)
|
||||
|
||||
expect(text().includes('Events older than')).toBe(warns)
|
||||
expect(patches()).toHaveLength(saved === chosen || warns ? 0 : 1)
|
||||
})
|
||||
|
||||
it('drops the warning when a longer period is chosen', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.resolve(settings({ activity_retention_days: 365 })),
|
||||
)
|
||||
await choose(30)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(patches()).toHaveLength(1)
|
||||
expect(patches()[0]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
|
||||
})
|
||||
|
||||
it('describes a shortening from Forever without a date', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
|
||||
),
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).toContain('Events older than 365 days will be removed')
|
||||
expect(text()).toContain(
|
||||
"Older events will be removed at the next cleanup. It can't be undone.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, save error reverts the control and Try again retries', () => {
|
||||
it.each([
|
||||
['500', () => Promise.reject(new ApiError(500))],
|
||||
['400', () => Promise.reject(new ApiError(400))],
|
||||
['403', () => Promise.reject(new ApiError(403))],
|
||||
['network', () => Promise.reject(new NetworkError())],
|
||||
])('on %s', async (_name, failure) => {
|
||||
let attempt = 0
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? failure()
|
||||
: Promise.resolve(settings({ activity_retention_days: 365 }))
|
||||
},
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).toContain("Couldn't save the retention period")
|
||||
expect(text()).toContain('Your previous setting is still in place.')
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
expect(line('retention-next')).toBe('7 Oct 2026, 03:00')
|
||||
expect(text()).not.toContain('Saved')
|
||||
|
||||
const retry = Array.from(retention()!.querySelectorAll('button')).find(
|
||||
(candidate) => candidate.textContent?.trim() === 'Try again',
|
||||
)
|
||||
retry!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(2)
|
||||
expect(patches()[1]![1].body).toBe(JSON.stringify({ activity_retention_days: 365 }))
|
||||
expect(text()).toContain('Saved')
|
||||
expect(text()).not.toContain("Couldn't save")
|
||||
})
|
||||
|
||||
it('retries an applied shortening without asking again', async () => {
|
||||
let attempt = 0
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? Promise.reject(new ApiError(500))
|
||||
: Promise.resolve(settings({ activity_retention_days: 30 }))
|
||||
},
|
||||
)
|
||||
await choose(30)
|
||||
button('Apply 30 days')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(text()).not.toContain('Events older than')
|
||||
expect(text()).toContain("Couldn't save the retention period")
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
|
||||
Array.from(retention()!.querySelectorAll('button'))
|
||||
.find((candidate) => candidate.textContent?.trim() === 'Try again')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patches()).toHaveLength(2)
|
||||
expect(text()).not.toContain('Events older than')
|
||||
})
|
||||
|
||||
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.reject(new ApiError(403)),
|
||||
)
|
||||
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(getMeMock).toHaveBeenCalled()
|
||||
expect(retention()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no card alert on a 401', async () => {
|
||||
await mountPage(
|
||||
'owner',
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.reject(new ApiError(401)),
|
||||
)
|
||||
|
||||
await choose(365)
|
||||
|
||||
expect(text()).not.toContain("Couldn't save")
|
||||
expect(selectedLabel()).toBe('90 days')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, offline disables the controls', () => {
|
||||
it('disables the select and the warning buttons', async () => {
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
await choose(30)
|
||||
|
||||
const online = vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper!.findComponent(USelectMenu).props('disabled')).toBe(true)
|
||||
expect(button('Apply 30 days')!.disabled).toBe(true)
|
||||
expect(button('Cancel')!.disabled).toBe(true)
|
||||
online.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings retention, the French interface', () => {
|
||||
it('words the card in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountPage('owner', () => Promise.resolve(settings()))
|
||||
|
||||
expect(text()).toContain('Conservation')
|
||||
expect(text()).toContain('Conserver l’activité pendant')
|
||||
expect(text()).toContain('Dernier nettoyage')
|
||||
expect(text()).toContain('Prochain nettoyage')
|
||||
expect(selectedLabel()).toBe('90 jours')
|
||||
})
|
||||
})
|
||||
+107
-6
@@ -165,6 +165,18 @@ const verifiedSession: ApiMock = {
|
||||
}
|
||||
|
||||
const updateRunningVersion = 'v2.2.0-beta'
|
||||
const newCardAbsentReason = 'new card absent from board frame (dashed placeholder)'
|
||||
const homeActivityMock: ApiMock = {
|
||||
status: 200,
|
||||
body: { items: [], total: 0, page: 1, size: 5 },
|
||||
}
|
||||
const workspaceSettingsBody = {
|
||||
version: updateRunningVersion,
|
||||
activity_retention_days: 90,
|
||||
activity_last_cleanup_at: '2026-10-06T03:00:00Z',
|
||||
activity_last_cleanup_removed: 142,
|
||||
activity_next_cleanup_at: '2026-10-07T03:00:00Z',
|
||||
}
|
||||
const updateLatestTag = 'v2.3.0'
|
||||
const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0'
|
||||
const updateReleasedAt = '2026-09-24T08:00:00Z'
|
||||
@@ -291,7 +303,7 @@ function updateApi(
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
|
||||
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
|
||||
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
|
||||
'GET /v1/system/update': { status: 200, body: status },
|
||||
'POST /v1/system/update/check': check,
|
||||
}
|
||||
@@ -545,6 +557,7 @@ const newArticleBody = 'Small rooms show every grout line, so we start with the
|
||||
function newArticleHomeApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
...overrides,
|
||||
}
|
||||
@@ -2378,7 +2391,7 @@ function cleanupApi(
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': sessionWithRole(role),
|
||||
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
|
||||
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
|
||||
'GET /v1/system/update': { status: 200, hang: true },
|
||||
[purgeSettingsPath]: settings,
|
||||
...overrides,
|
||||
@@ -2392,8 +2405,9 @@ function settingsBody(body: unknown): ApiMock {
|
||||
function cleanupState(
|
||||
api: Record<string, ApiMock>,
|
||||
steps?: (page: Page) => Promise<void>,
|
||||
exception?: DiffException,
|
||||
): VisualState {
|
||||
return { path: '/settings', api, steps }
|
||||
return { path: '/settings', api, steps, exception }
|
||||
}
|
||||
|
||||
async function openCleanupDialog(page: Page) {
|
||||
@@ -2486,7 +2500,11 @@ const cleanupStates: Record<string, VisualState> = {
|
||||
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
|
||||
}),
|
||||
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
|
||||
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
|
||||
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true }), undefined, {
|
||||
maxPercent: 17.5,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
}),
|
||||
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
|
||||
'Loading preview': cleanupState(
|
||||
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
|
||||
@@ -2960,6 +2978,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||
},
|
||||
@@ -3102,13 +3121,34 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
|
||||
Checking: {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.3,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
|
||||
},
|
||||
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Update available': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
|
||||
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) },
|
||||
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) },
|
||||
Downloading: {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateDownloading()),
|
||||
exception: {
|
||||
maxPercent: 10.6,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
},
|
||||
Verifying: {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateJob('verifying')),
|
||||
exception: {
|
||||
maxPercent: 10.8,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
},
|
||||
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
|
||||
'Restart: confirm': {
|
||||
path: '/settings',
|
||||
@@ -3148,14 +3188,29 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Download failed': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 10.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateJob('failed', { error: 'download' })),
|
||||
},
|
||||
'Verification failed': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 10.5,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
|
||||
},
|
||||
'Too many requests (429)': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.3,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateNeverChecked(), {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '42' },
|
||||
@@ -3163,10 +3218,20 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'Not prepared': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: updateApi('owner', updateAvailable()),
|
||||
},
|
||||
'Not prepared: command copied': {
|
||||
path: '/settings',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
origin: integrationOrigin,
|
||||
api: updateApi('owner', updateAvailable()),
|
||||
steps: async (page) => {
|
||||
@@ -3491,52 +3556,88 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
home: {
|
||||
Default: {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 13.5,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
},
|
||||
},
|
||||
Loading: {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 18.6,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
|
||||
},
|
||||
},
|
||||
'Empty, first run': {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 11.4,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||
'GET /v1/article/': { status: 200, body: [] },
|
||||
},
|
||||
},
|
||||
Error: {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 25.3,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
now: boardNow,
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||
'GET /v1/article/': { status: 500 },
|
||||
},
|
||||
},
|
||||
'Update available (owner)': {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
'GET /v1/system/update': { status: 200, body: updateAvailable() },
|
||||
},
|
||||
},
|
||||
'Update ready to install (owner)': {
|
||||
path: '/home',
|
||||
exception: {
|
||||
maxPercent: 11.9,
|
||||
reason: newCardAbsentReason,
|
||||
approvedBy: 'fallen',
|
||||
},
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/activity': homeActivityMock,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
'GET /v1/system/update': { status: 200, body: updateJob('ready') },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user