feat: settings page, sidebar and drawer destination "Settings" (icon settings, after Integration), route /settings (verified), showing only the running build's Version

This commit is contained in:
fsylvain committed 2026-09-30 00:19:29 +02:00
1 parent b146dfead7
commit 162fea4afb
26 files changed
+805 -45

No files matched your search

+2
View File
@@ -44,6 +44,7 @@ type UseCases struct {
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
DeleteImageUseCase *useCases.DeleteImageUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
}
type Repositories struct {
@@ -112,6 +113,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
DeleteImageUseCase: useCases.NewDeleteImageUseCase(repos.ImageRepo),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
}
}
+24
View File
@@ -0,0 +1,24 @@
package settings
import (
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
)
type Handler struct {
GetSettingsUseCase *useCases.GetSettingsUseCase
}
func NewHandler(getSettings *useCases.GetSettingsUseCase) *Handler {
return &Handler{GetSettingsUseCase: getSettings}
}
func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
body, err := json.Marshal(h.GetSettingsUseCase.GetSettings())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
+15
View File
@@ -5,6 +5,7 @@ import (
"RenewCMS/api/handlers/auth"
"RenewCMS/api/handlers/frontend"
"RenewCMS/api/handlers/image"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
@@ -115,6 +116,19 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
return r
}
func NewSettingsRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", settingsHandler.GetSettings)
return r
}
func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r := chi.NewRouter()
@@ -128,6 +142,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/article", NewArticleRouter(container))
})
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r
+1
View File
@@ -62,6 +62,7 @@ func run() error {
if err != nil {
return err
}
settings.Version = version
container, closeDb := api.InitContainer(settings)
defer closeDatabase(closeDb)
+1 -1
View File
@@ -17,7 +17,7 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
<span class="font-semibold">RenewCMS</span> the shell renders today
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
to the sidebar's own collapsed state, with items Home, Articles, Users and Integration, in that order
to the sidebar's own collapsed state, with items Home, Articles, Users, Integration and Settings, in that order
And the sidebar's #footer slot renders a UserMenu component, not the plain
<span>Log out</span> text the shell renders today
And UserMenu is the account menu specified in features/profile_page.feature, anchored to a
+5 -5
View File
@@ -130,14 +130,14 @@ Feature: Nuxt UI shell foundation and router guards
Then the alert above the title contains "Your session expired."
And the footer area outside the card contains a "Forgot password?" link
Scenario: DashboardShell renders the four navigation destinations
Scenario: DashboardShell renders the five navigation destinations
Given the current route is /article
When DashboardShell mounts
Then the sidebar navigation lists exactly four destinations in this order: "Home" linking to
/home, "Articles" linking to /article, "Users" linking to /users, and "Integration" linking
to /integration
Then the sidebar navigation lists exactly five destinations in this order: "Home" linking to
/home, "Articles" linking to /article, "Users" linking to /users, "Integration" linking
to /integration, and "Settings" linking to /settings
And the "Articles" destination has aria-current="page"
And none of "Home", "Users" and "Integration" has aria-current set
And none of "Home", "Users", "Integration" and "Settings" has aria-current set
Scenario: DashboardShell shows the offline banner when the browser reports offline
Given navigator.onLine is false
+2 -2
View File
@@ -41,7 +41,7 @@ Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest se
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
both render outside/above the card content as specified.
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
4 links in order (Home→/home, Articles→/article, Users→/users, Integration→/integration), and only the Articles link
5 links in order (Home→/home, Articles→/article, Users→/users, Integration→/integration, Settings→/settings), and only the Articles link
has aria-current="page".
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
@@ -57,7 +57,7 @@ plus the components.md AuthShell/DashboardShell sections.
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
light and dark.
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 3 nav items, UserMenu footer;
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 5 nav items, UserMenu footer;
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
slideover, closes after navigating.
+2 -2
View File
@@ -18,8 +18,8 @@ Feature: Profile page, display name and the account menu
display_name in a response is read as ""; the session store gains an action that replaces
the session's displayName from the display_name of a PATCH /v1/user/me response
And the PageHeader shows breadcrumb "Profile" (mobile title "Profile"), the page title
"Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the four
navigation items (Home, Articles, Users, Integration) has aria-current="page" on /profile
"Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the five
navigation items (Home, Articles, Users, Integration, Settings) has aria-current="page" on /profile
And the page is at most 720 px wide with three ProfileCards in this order: Display name,
Account, Password; cards are separated by 20 px, have 20 px padding (16 px on mobile) and
inputs are 40 px high (44 px on mobile, 16 px text)
+131
View File
@@ -0,0 +1,131 @@
Feature: Settings page and GET /v1/settings
As a verified RenewCMS user in the shared workspace
I want a Settings page that shows the version of the RenewCMS build I am running
So that I can tell which build is deployed when I report a problem or check an upgrade
Background:
Given GET /v1/settings is mounted under /v1 in api/router.go in its own router, behind
jwtauth.Verifier, jwtauth.Authenticator, middleware.RequireCurrentToken and
middleware.RequireVerifiedUser, the same chain as NewUserRouter
And the response of a verified caller is 200 with the JSON body {"version": "<value>"}, one
snake_case field and nothing else, built from the domain entity settings.Settings in
internal/domain/settings
And GetSettingsUseCase (internal/infrastructure/useCases) is built with the version string and
depends only on the domain package; the handler in api/handlers/settings receives it
through NewHandler and never reads config, os or the environment
And config.Settings gains a Version field; cmd/server/main.go sets it from its own
main.version variable ("dev" unless set with -ldflags -X main.version) right after
config.NewSettingsFromEnv, and api/dependecyInjection.go passes settings.Version to the use
case; no os.Getenv or os.LookupEnv for the version appears outside cmd/server/main.go and
internal/infrastructure/config, and the version is captured once at startup
And /settings is a verified route (meta.auth "verified", meta.title "Settings") rendered as
SettingsPage inside DashboardShell, per docs/design/routes.md and docs/design/screens/settings.md
And SettingsPage calls GET /v1/settings once per navigation to it
And the PageHeader shows breadcrumb "Settings" (mobile title "Settings"), the page title
"Settings" and the subtitle "Workspace information."
And the page is at most 720 px wide, with padding 32 px on desktop and 20px 16px 88px on
mobile, and 20 px between the header and the card
And the page holds one card, --ui-bg with a 1 px --ui-border, 20 px padding on desktop and
16 px on mobile, containing a single row laid out as the board frame draws it: the label at
the left, the value at the right, vertically centred, 16 px apart
And the label is "Version" (15 px semibold, --ui-text-highlighted) and the value is the
server's version string in IBM Plex Mono 14 px, --ui-text-toned, with no "v" added by the
SPA
And the page has no action, toggle, link or input; every verified user sees it, owner and
admin alike
And DashboardShell's navigation gains "Settings" (icon settings, i-lucide-settings, linking to
/settings) after "Integration", in the sidebar and in the mobile drawer, so it lists five
destinations in this order: Home, Articles, Users, Integration, Settings
And skeleton placeholders show only after 150 ms of loading, so a fast response never flashes
them
And the Loading and Error states are not drawn in docs/design; they are decided in this spec
and covered by Vitest only (no visual state)
Scenario Outline: A verified caller gets the running build's version
Given the process was started with a config.Settings whose Version is "<version>"
And a verified user is logged in with a valid jwt cookie
When the client sends GET /v1/settings
Then the response status is 200
And the response body is exactly the JSON object {"version": "<version>"}
Examples:
| version |
| v2.2.0-beta |
| dev |
Scenario: The use case returns the version it was built with
Given GetSettingsUseCase is built with the version "v2.2.0-beta"
When GetSettings is called
Then it returns a settings.Settings whose Version is "v2.2.0-beta"
Scenario: A request without a jwt cookie is refused
When the client sends GET /v1/settings with no jwt cookie
Then the response status is 401
And the use case is not consulted for the response
Scenario: A token whose version no longer matches the account is refused
Given user id 7 exists with token version 1
And the jwt cookie was minted for user 7 with token version 0
When the client sends GET /v1/settings with that cookie
Then the response status is 401
And the response body is apperr.UnauthorizedErrorMessage
Scenario: An unverified account is refused
Given user id 8 exists with is_verified false and a valid jwt cookie
When the client sends GET /v1/settings with that cookie
Then the response status is 403
And the response body is apperr.VerificationRequiredErrorMessage
And the response does not contain the version
Scenario: Default — the Version row shows the server's version
Given GET /v1/settings resolves 200 with {"version": "v2.2.0-beta"}
When the user navigates to /settings
Then the breadcrumb reads "Settings" (mobile title "Settings")
And the header shows the title "Settings" and the subtitle "Workspace information."
And the card shows one row: the label "Version" and the value "v2.2.0-beta"
And the "Settings" nav item has aria-current="page" and no other nav item does
Scenario: The value is shown exactly as reported, with no prefix added
Given GET /v1/settings resolves 200 with {"version": "dev"}
When the user navigates to /settings
Then the value reads "dev"
Scenario: Navigation — "Settings" sits after Integration
Given the user is on /home
When DashboardShell mounts
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration, Settings in
that order
And "Settings" has the settings icon and links to /settings
Scenario: Loading — the value becomes a skeleton after 150 ms while the request is in flight
Given GET /v1/settings has not yet resolved
When the user navigates to /settings
Then the label "Version" is visible at once, and nothing stands in for the value during the
first 150 ms
And after 150 ms a skeleton replaces the value only, and the card is aria-busy="true"
When GET /v1/settings then resolves 200 with {"version": "v2.2.0-beta"}
Then the skeleton is replaced by "v2.2.0-beta" and aria-busy is no longer set
Scenario Outline: Error — a 500 or a network failure replaces the row with an inline alert and a retry
Given GET /v1/settings <failure>
When the user navigates to /settings
Then the card shows an error alert with no close button and no title, reading "Couldn't reach
RenewCMS. Check your connection and try again.", with an action "Try again" (icon rotate-cw)
And the "Version" row is not shown
And the header (title and subtitle) is still shown
When the user activates "Try again"
Then the Loading state shows and GET /v1/settings is sent again
When it then resolves 200 with {"version": "v2.2.0-beta"}
Then the Default state's row replaces the alert
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: A 401 or an unverified 403 is handled globally, not by the page
Given GET /v1/settings resolves 401
When the user navigates to /settings
Then useApi's global handling redirects to "/login?redirect=/settings&reason=expired"
And SettingsPage shows no error alert of its own
+92
View File
@@ -0,0 +1,92 @@
# QA: Settings page and GET /v1/settings
## Automated Go (`task test`, package `test`, black-box)
Files: `test/settings_test.go` (use case) and `test/settings_http_test.go` (HTTP). Fakes: reuse
`mockUserRepo` from `test/user_test.go`; no settings repository exists, so none is needed. Name any
new fake uniquely, since all fakes share one package.
Setup shared by the HTTP cases:
1. Call `validator.InitValidator()` once.
2. Build `token := jwtauth.New("HS256", []byte("test-secret"), nil)`, `getUser :=
useCases.NewGetUserUseCase(userRepo)` and `settingsHandler :=
settings.NewHandler(useCases.NewGetSettingsUseCase(version))`.
3. Mount on a fresh `chi.NewRouter()` mirroring `NewSettingsRouter`:
`r.Route("/v1/settings", ...)` with `jwtauth.Verifier(token)`, `jwtauth.Authenticator(token)`,
`middleware.RequireCurrentToken(getUser)`, `middleware.RequireVerifiedUser(getUser)`, then
`r.Get("/", settingsHandler.GetSettings)`. No live server, no SQLite, no SMTP.
4. Mint cookies with `token.Encode(map[string]any{"user_id": uint32(id), "token_version":
uint32(v)})`, as in `test/stale_token_invalidation_http_test.go`.
Cases:
5. Use case, table-driven over versions "v2.2.0-beta" and "dev": `NewGetSettingsUseCase(v)
.GetSettings().Version == v`. The rule that `internal/domain/settings` and the use case do not
import `config`, `os`, `net/http` or `gorm` is a review item, not a Go test.
6. Verified user id 7 (`IsVerified: true`, `TokenVersion: 0`), matching cookie, version
"v2.2.0-beta": `GET /v1/settings` returns 200 and the body decodes to exactly one key
`version` equal to "v2.2.0-beta" (decode into `map[string]any` and assert `len == 1`). Repeat
with version "dev".
7. No cookie: 401. Do not assert an exact body (it is produced by `jwtauth.Authenticator`).
8. User 7 with `TokenVersion: 1`, cookie encoding `token_version: 0`: 401, body exactly
`apperr.UnauthorizedErrorMessage`.
9. User 8 with `IsVerified: false`, matching cookie: 403, body exactly
`apperr.VerificationRequiredErrorMessage`, and the body does not contain the version string.
10. After building the router, call `t.Setenv("ENVIRONMENT", "development")` and re-request as in
case 6: the response is unchanged (nothing is read from the environment at request time).
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `GET /v1/settings`. Mount SettingsPage inside DashboardShell with a
resolved verified session and a test router at /settings. Use fake timers for the 150 ms cases. No
test freezes the system date.
1. **Default** — resolve `{ version: 'v2.2.0-beta' }`; assert breadcrumb "Settings", title
"Settings", subtitle "Workspace information.", one row with the label "Version" and the value
exactly "v2.2.0-beta", `request` called once with `/v1/settings`, and no button, link, switch or
input inside the card.
2. **No prefix** — resolve `{ version: 'dev' }`; assert the value text is exactly "dev".
3. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
Settings in that order with the settings icon on the last; on /settings only "Settings" carries
`aria-current="page"`.
4. **Loading** — unresolved request; at 149 ms assert no skeleton and that "Version" is present; at
150 ms assert a skeleton in place of the value and `aria-busy="true"` on the card; resolve and
assert the value and no `aria-busy`.
5. **Error, Examples-driven** — reject with `ApiError(500)`, then `NetworkError`; assert the alert
text "Couldn't reach RenewCMS. Check your connection and try again.", the "Try again" action, no
close button, no title and no "Version" row, with the header still shown. Click "Try again";
assert Loading and a second `request`; resolve and assert the Default row.
6. **401** — reject with `ApiError(401)`; assert the page shows no alert of its own (the redirect
itself is covered by `useApi`'s existing tests).
7. **Router meta** — assert the `/settings` route has `meta.auth === 'verified'` and
`meta.title === 'Settings'`.
8. **Amended shell specs** — update the existing Vitest for `nuxt_ui_shell_foundation` case 14,
`dashboard_shell_branding`, users_screen and profile_page to five nav items, in the same change.
## Visual (`task visual:check -- settings`, desktop and mobile, light and dark)
Reference: `docs/design/screens/settings.md`; board group "Settings" in `docs/design/source/RenewCMS
Screens Back-office.dc.html`; exact values from the `isSettings` block of
`docs/design/source/AppFrame.dc.html`. Add the mock to `web/visual/states.ts`. The gate is hard:
every capture within 10 %.
- [ ] Default: mock `GET /v1/settings` returning `{ "version": "v2.2.0-beta" }` (the board's
sample). Layout as the frame draws it: the header is a 20/28 semibold title with the muted
subtitle 2 px below; the card is the header's next sibling, 20 px below; the card is
`--ui-bg` with a 1 px `--ui-border` and no drawn radius; the row is a flex row with
`justify-content: space-between`, `align-items: center` and a 16 px gap, the label at the
left and the mono value at the right; the whole page is at most 720 px wide.
- [ ] Not drawn, no visual state: Loading and Error. Do not add them to `web/visual/states.ts`;
Vitest cases 4 and 5 cover them.
- [ ] Not covered by screenshots: keyboard order (Tab reaches the "Settings" nav item; on Error,
Tab reaches "Try again", and Enter and Space activate it); focus after a navigation; the
skeleton pulse under reduced motion.
## Manual verification
- With a build made through `task build` or `task release` (`-ldflags -X main.version=...`),
open /settings and confirm the value equals the injected version. A build without the flag must
show `dev`.
- Confirm against a running `task dev` API that an unverified account is sent to /register/pending
and a logged-out browser to /login?redirect=/settings&reason=expired.
- Confirm the embedded SPA (built without `-tags nofrontend`) serves /settings on a hard refresh,
since Vitest never exercises the embedded frontend.
+3 -3
View File
@@ -18,8 +18,8 @@ Feature: Users screen and InviteUserModal
and rows are keyed by kind and id, because a user and an invite can share a numeric id
And DashboardShell's navigation gains "Users" (icon users, linking to /users) between
"Articles" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
Articles, Users, Integration in that order; "Users" has aria-current="page" on /users; the
"four navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
Articles, Users, Integration, Settings in that order; "Users" has aria-current="page" on /users; the
"five navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
dashboard_shell_branding.feature are amended accordingly
And the PageHeader shows breadcrumb "Users" (mobile title "Users")
And the page header is the title "Users" with the subtitle "Everyone who can sign in and work
@@ -108,7 +108,7 @@ Feature: Users screen and InviteUserModal
Scenario: Navigation — "Users" sits between Articles and Integration
Given the user is on /users
When DashboardShell mounts
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration in that order
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration, Settings in that order
And "Users" has the users icon, links to /users and has aria-current="page"
Scenario: Loading — skeleton rows after 150 ms while the first request, or a retry, is in flight
+3 -3
View File
@@ -5,7 +5,7 @@
The invitations API is already built and covered by features/invitations.qa.md: this slice has no
Go change, so it has no HTTP-level Go section.
The shell specs were amended to four destinations (Home, Articles, Users, Integration):
The shell specs were amended to five destinations (Home, Articles, Users, Integration, Settings):
nuxt_ui_shell_foundation.feature / .qa.md (case 14) and dashboard_shell_branding.feature. Their
Vitest tests must be updated in the same change.
@@ -25,8 +25,8 @@ part). Run each layout case at a desktop and a below-md width (mock `matchMedia`
1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", rows in fixture order,
"Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and
the fixture's absolute dates; assert no accepted row shows an email.
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration in
that order, and "Users" carries aria-current="page" on /users.
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
Settings in that order, and "Users" carries aria-current="page" on /users.
3. **Loading** — unresolved `GET`; with fake timers assert nothing is drawn at 149 ms; at 150 ms
assert 5 skeleton rows and `aria-busy="true"`; resolve and assert the Default rows.
4. **Only you** — a single "marta" item; assert the row, the hint text and "Invite user".
@@ -0,0 +1,5 @@
package settings
type Settings struct {
Version string `json:"version"`
}
@@ -31,6 +31,7 @@ type Settings struct {
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
TrustedProxies []string
Version string
}
func (s Settings) IsProduction() bool {
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/settings"
type GetSettingsUseCase struct {
version string
}
func NewGetSettingsUseCase(version string) *GetSettingsUseCase {
return &GetSettingsUseCase{version}
}
func (g *GetSettingsUseCase) GetSettings() settings.Settings {
return settings.Settings{Version: g.version}
}
+123
View File
@@ -0,0 +1,123 @@
package test
import (
"RenewCMS/api/handlers/settings"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newSettingsRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
settingsHandler := settings.NewHandler(useCases.NewGetSettingsUseCase(version))
r := chi.NewRouter()
r.Route("/v1/settings", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/", settingsHandler.GetSettings)
})
return r
}
func TestSettingsHttp(t *testing.T) {
validator.InitValidator()
for _, version := range []string{"v2.2.0-beta", "dev"} {
t.Run("A verified caller gets the running build's version: "+version, func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(userRepo, token, version)
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != `{"version":"`+version+`"}` {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
}
t.Run("The response does not depend on the environment at request time", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
t.Setenv("ENVIRONMENT", "development")
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != `{"version":"v2.2.0-beta"}` {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
router := newSettingsRouter(newMockUserRepo(), token, "v2.2.0-beta")
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "v2.2.0-beta") {
t.Errorf("body leaks the version: %q", rec.Body.String())
}
})
t.Run("A token whose version no longer matches the account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
})
t.Run("An unverified account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
userRepo := newMockUserRepo()
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
router := newSettingsRouter(userRepo, token, "v2.2.0-beta")
cookie := mintGetCurrentUserCookie(t, token, 8, 0)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", rec.Code, rec.Body.String())
}
if rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("unexpected body: %q", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "v2.2.0-beta") {
t.Errorf("body leaks the version: %q", rec.Body.String())
}
})
}
+20
View File
@@ -0,0 +1,20 @@
package test
import (
"RenewCMS/internal/infrastructure/useCases"
"testing"
)
func TestGetSettingsUseCase(t *testing.T) {
for _, version := range []string{"v2.2.0-beta", "dev"} {
t.Run("The use case returns the version it was built with: "+version, func(t *testing.T) {
useCase := useCases.NewGetSettingsUseCase(version)
got := useCase.GetSettings()
if got.Version != version {
t.Errorf("expected version %s, got %q", version, got.Version)
}
})
}
}
+1
View File
@@ -41,6 +41,7 @@ const navItems = computed(() =>
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
@@ -58,14 +58,14 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the four navigation destinations', () => {
it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the five navigation destinations', () => {
it('lists Home, Articles, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
.findAll('nav[aria-label="Main"] a')
.map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration'])
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration', '/settings'])
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
@@ -78,6 +78,27 @@ describe('DashboardShell renders the four navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same five destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const links = Array.from(
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/users',
'/integration',
'/settings',
])
expect(links[4].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[4].getAttribute('aria-current')).toBe('page')
})
it('marks Users with aria-current on /users', async () => {
const wrapper = await mountShell('/users')
@@ -211,11 +232,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the four navigation items current on /profile', async () => {
it('marks none of the five navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
expect(links).toHaveLength(5)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
+15
View File
@@ -0,0 +1,15 @@
import { useApi } from '@/composables/useApi'
export interface WorkspaceSettings {
version: string
}
export function useSettings() {
const { request } = useApi()
function getSettings(): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>('/v1/settings/')
}
return { getSettings }
}
+8 -22
View File
@@ -65,10 +65,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
let resolveMe: (value: { verified: boolean }) => void = () => {}
const getMe = vi.fn(
() =>
new Promise<{ verified: boolean }>(
(resolve) => (resolveMe = resolve),
),
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
)
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -124,9 +121,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
await router.push('/article')
mountApp()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('A session-only route admits an unverified session', async () => {
@@ -201,9 +196,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
)
expect(app.text()).toContain('Home')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -219,16 +212,12 @@ describe('Nuxt UI shell foundation and router guards', () => {
const app = mountApp()
await vi.waitFor(() =>
expect(app.find('[role="alert"] h1').text()).toBe(
'Something went wrong',
),
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
)
expect(router.currentRoute.value.path).toBe('/article')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -239,6 +228,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
{ path: '/home', title: 'Home' },
{ path: '/article', title: 'Articles' },
{ path: '/integration', title: 'Integration' },
{ path: '/settings', title: 'Settings' },
])(
'Placeholder verified route $path renders inside DashboardShell with title $title',
async ({ path, title }) => {
@@ -251,14 +241,10 @@ describe('Nuxt UI shell foundation and router guards', () => {
await flushPromises()
const headings = app.findAll('h1')
expect(headings.some((heading) => heading.text() === title)).toBe(
true,
)
expect(headings.some((heading) => heading.text() === title)).toBe(true)
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
const activeLink = app.find(
`a[href="${path}"][aria-current="page"]`,
)
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
expect(activeLink.exists()).toBe(true)
},
)
+5
View File
@@ -86,6 +86,11 @@ const router = createRouter({
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { ApiError } from '@/composables/useApi'
import { useSettings } from '@/composables/useSettings'
const SKELETON_DELAY_MS = 150
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
type LoadState = 'loading' | 'ready' | 'error'
const { getSettings } = useSettings()
const loadState = ref<LoadState>('loading')
const version = ref('')
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.status === 403)
}
async function loadSettings() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const settings = await getSettings()
version.value = settings.version
loadState.value = 'ready'
} catch (error) {
if (!isHandledGlobally(error)) {
loadState.value = 'error'
}
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
onMounted(loadSettings)
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<DashboardShell>
<div class="flex max-w-180 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
<p class="text-muted">Workspace information.</p>
</div>
<div
data-testid="settings-card"
:aria-busy="loadState === 'loading' && showSkeleton ? 'true' : undefined"
class="border border-default bg-default p-4 lg:p-5"
>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="UNREACHABLE_COPY"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadSettings"
>
Try again
</UButton>
</template>
</UAlert>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-lg font-semibold text-highlighted">Version</span>
<USkeleton
v-if="loadState === 'loading' && showSkeleton"
data-testid="settings-skeleton"
class="h-5 w-24 rounded-none bg-accented"
/>
<span
v-else-if="loadState === 'ready'"
data-testid="settings-version"
class="font-mono text-base text-toned"
>{{ version }}</span
>
</div>
</div>
</div>
</DashboardShell>
</template>
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the four navigation items as the current page', async () => {
it('marks none of the five navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
expect(links).toHaveLength(5)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
@@ -0,0 +1,183 @@
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 router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
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 UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
let requestMock: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
async function mountPage(settings: () => Promise<unknown>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === SETTINGS_PATH) {
return settings()
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request: requestMock,
})
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function card() {
return document.body.querySelector('[data-testid="settings-card"]')!
}
function versionValue() {
return document.body.querySelector('[data-testid="settings-version"]')
}
function settingsCalls() {
return requestMock.mock.calls.filter(([path]) => path === SETTINGS_PATH)
}
function tryAgainButton() {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Try again',
)
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Default: the Version row shows the server version', () => {
it('shows the breadcrumb, the header, the label and the value, with Settings current', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }))
expect(
document.body.querySelector('nav[aria-label="breadcrumb"]')?.textContent?.trim(),
).toBe('Settings')
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Settings')
expect(document.body.textContent).toContain('Workspace information.')
expect(card().textContent).toContain('Version')
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
expect(card().hasAttribute('aria-busy')).toBe(false)
expect(settingsCalls()).toHaveLength(1)
const current = document.body.querySelectorAll(
'nav[aria-label="Main"] a[aria-current="page"]',
)
expect(current).toHaveLength(1)
expect(current[0].getAttribute('href')).toBe('/settings')
})
it('shows the value exactly as reported, with no prefix added', async () => {
await mountPage(() => Promise.resolve({ version: 'dev' }))
expect(versionValue()?.textContent?.trim()).toBe('dev')
})
it('holds no action, link or input inside the card', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }))
expect(card().querySelector('button, a, input')).toBeNull()
})
})
describe('Router meta', () => {
it('declares /settings as a verified route titled Settings', () => {
const route = router.getRoutes().find((candidate) => candidate.path === '/settings')
expect(route?.meta.auth).toBe('verified')
expect(route?.meta.title).toBe('Settings')
})
})
describe('Loading: skeleton for the value after 150 ms', () => {
it('shows the label at once, a skeleton from 150 ms, then the value', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferred<{ version: string }>()
await mountPage(() => pending.promise)
expect(card().textContent).toContain('Version')
vi.advanceTimersByTime(149)
await flushPromises()
expect(card().hasAttribute('aria-busy')).toBe(false)
expect(card().querySelector('.animate-pulse')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(card().getAttribute('aria-busy')).toBe('true')
expect(card().querySelector('.animate-pulse')).not.toBeNull()
expect(card().textContent).toContain('Version')
pending.resolve({ version: 'v2.2.0-beta' })
await flushPromises()
expect(card().querySelector('.animate-pulse')).toBeNull()
expect(card().hasAttribute('aria-busy')).toBe(false)
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
})
})
describe('Error: a 500 or a network failure replaces the row with an alert and a retry', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
await mountPage(failure)
const alert = card().querySelector('[role="alert"]')!
expect(alert.textContent).toContain(UNREACHABLE_COPY)
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
expect(tryAgainButton()).toBeTruthy()
expect(card().textContent).not.toContain('Version')
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Settings')
expect(document.body.textContent).toContain('Workspace information.')
requestMock.mockImplementation(() => Promise.resolve({ version: 'v2.2.0-beta' }))
tryAgainButton()?.click()
await flushPromises()
expect(settingsCalls()).toHaveLength(2)
expect(card().querySelector('[role="alert"]')).toBeNull()
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
})
})
describe('A 401 or an unverified 403 is handled globally, not by the page', () => {
it.each([401, 403])('shows no error alert of its own on a %i', async (status) => {
await mountPage(() => Promise.reject(new ApiError(status)))
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(tryAgainButton()).toBeUndefined()
})
})
+9
View File
@@ -869,6 +869,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
},
settings: {
Default: {
path: '/settings',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/settings/': { status: 200, body: { version: 'v2.2.0-beta' } },
},
},
},
integration: {
Default: {
path: '/integration',