113 lines
8.4 KiB
Markdown
113 lines
8.4 KiB
Markdown
# QA: Users screen and InviteUserModal
|
|
|
|
## Implementation note for coder (informational, not a QA assertion)
|
|
|
|
The invitations API is already built and covered by features/invitations.qa.md: this slice has no
|
|
Go change in this slice; the API shape was extended by user_roles.feature, so it has no HTTP-level Go section.
|
|
|
|
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.
|
|
|
|
The Invite sent toast uses `useToast().add({ id: 'invite-sent-{id}', color: 'success', icon:
|
|
'i-lucide-circle-check', title: 'Invite sent', description: '{email} has 3 days to accept.' })`.
|
|
The list items are keyed by `kind` and `id`.
|
|
|
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
|
|
|
Mock `useApi`'s `request` for `GET /v1/user/` and `POST /v1/user/invite` per case; no real backend.
|
|
Mount UsersPage inside DashboardShell with a resolved verified session (id 1, username "marta") and
|
|
a test router at /users. The Default fixture is the Background's 5 items; the invite fixture is
|
|
id 3, email "lea.martin@renew.example". No test freezes the system date. The specs only assert on
|
|
the fixture's absolute dates; the sent row's "Today" label is not asserted anywhere (see the Visual
|
|
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.
|
|
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".
|
|
5. **Error, Examples-driven** — reject with `ApiError(500)`, then `NetworkError`; assert the alert
|
|
copy, the "Try again" action, no close button and no rows; click "Try again"; assert `request`
|
|
is called again, then resolve the Default fixture and assert its rows.
|
|
6. **Invite: default** — click "Invite user"; assert title, text, an empty focused Email with its
|
|
placeholder, "Cancel", "Send invite"; below md assert the pinned button is hidden while open.
|
|
7. **Invite: validation, Examples-driven** — "sam.oliveira@renew" then blur; "not-an-email" then
|
|
submit; empty then submit; assert the field error, no `request` call, and focus on the field.
|
|
Assert that fixing the value clears the error.
|
|
8. **Invite: sending** — valid address, submit; assert one `POST /v1/user/invite` with body
|
|
`{"email":"lea.martin@renew.example"}`, the field and "Cancel" disabled, "Sending…", and
|
|
Escape and backdrop click not closing the modal.
|
|
9. **Invite sent** — resolve 201 with the invite fixture; assert the modal closes, `GET /v1/user/`
|
|
was not called a second time, the list has 6 rows with the new Pending row first and the
|
|
others unchanged, the first row has the `--ui-bg-elevated` background class, and the toast
|
|
was raised with id "invite-sent-3", color success, icon "i-lucide-circle-check", title
|
|
"Invite sent" and description "lea.martin@renew.example has 3 days to accept." Assert nothing
|
|
about the "Today" label.
|
|
10. **Replacement of an expired invite** — invite "p.huber@gmail.com", resolve 201; assert exactly
|
|
one row for that address, first and Pending.
|
|
11. **Invite sent into Only you** — assert the new row above "marta" and the hint gone.
|
|
12. **409, Examples-driven** — reject with `ApiError(409)` carrying each of the two texts; assert
|
|
the matching field error, the address kept, no alert. Also assert an unknown 409 body falls
|
|
back to "This address already has an account.".
|
|
13. **400 got through** — reject with `ApiError(400)`; assert the alert and the field error.
|
|
14. **Rate limited (429)** — reject with `ApiError(429)` carrying `apperr.TooManyRequestsErrorMessage`
|
|
as body; assert the alert "Too many invites. Wait a moment, then try again." above the buttons
|
|
(the raw body is not shown), the address kept, the form re-enabled, no field error and the
|
|
list unchanged; submit again, resolve 201, and assert the closed modal, the row and the toast.
|
|
15. **Failed, Examples-driven** — `ApiError(500)`, `ApiError(500)` with the API's own text as body,
|
|
`NetworkError`; assert the alert "Couldn't send the invite. Try again in a moment." (shown
|
|
once, not the raw body), the address kept, no field error, the list unchanged.
|
|
16. **Retry after failure** — from 15, submit again; assert `request` is called a second time with
|
|
the same body; resolve 201 and assert the closed modal, the row and the toast.
|
|
17. **Cancel discards** — type, click "Cancel"; assert immediate close, no `request`, an empty
|
|
field on reopening, and focus back on the opening button.
|
|
|
|
## Visual (task visual:check -- users, desktop and mobile, light and dark)
|
|
|
|
Reference: `docs/design/screens/users.md`, board group "Users" in `docs/design/source/RenewCMS
|
|
Screens Back-office.dc.html`, exact values in `docs/design/source/AppFrame.dc.html`. Add each
|
|
state's mock to `web/visual/states.ts` with the board's own sample data. The gate is hard: every
|
|
capture of every non-skipped state must be within 10 %, for both viewports and both themes.
|
|
|
|
- [ ] Default: the 5 board rows (`sam.oliveira@renew.example`, `p.huber@gmail.com`, `ines` with
|
|
"Inès Duarte", `jonas`, `marta` with "you"), dates 27 Sep 2026, 3 Aug 2026, 18 Jun 2026,
|
|
2 Apr 2026, 12 Mar 2026; session user `marta`.
|
|
- [ ] Loading: 5 skeleton rows; the setup must give skeletons time to show (150 ms) before the capture.
|
|
- [ ] Only you: the single `marta` row plus the hint.
|
|
- [ ] Error: the alert and "Try again", no table or cards; the header button is still drawn.
|
|
- [ ] Invite: default: modal over the Default list, empty focused Email; the mobile pinned button hidden.
|
|
- [ ] Invite: validation: value `sam.oliveira@renew`, error copy.
|
|
- [ ] Invite: sending: value `lea.martin@renew.example`, disabled field, "Sending…".
|
|
- [ ] Invite: already a user (409): value `jonas@renew.example`, error "This address already has an account.".
|
|
- [ ] Invite: failed: value `lea.martin@renew.example`, alert "Couldn't send the invite. Try again in a moment.".
|
|
- [ ] skip: clock-sensitive. Invite sent: `skip: 'clock-sensitive'` in `web/visual/states.ts`
|
|
("Today"). It prints SKIP, and is never investigated or date-frozen. Its Vitest coverage is case 9.
|
|
- [ ] Not drawn, no visual state: the pending-invite 409 variant and the 429 alert. They are
|
|
covered by Vitest only (cases 12 and 14); do not add them to `web/visual/states.ts`.
|
|
- [ ] Layout, taken from the board frame (`AppFrame.dc.html`), not `components.md`: desktop header
|
|
row is title block left and the 32 px "Invite user" button right, aligned to the bottom of
|
|
the row; on mobile the button is 16 px from left, right and bottom, 48 px high, 18 px icon.
|
|
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches "Invite user", the modal
|
|
focus starts on Email, then Cancel, then "Send invite", focus is trapped, Esc closes it in
|
|
every dismissible state and is blocked while Sending, focus returns to the opening button);
|
|
hover and active on the buttons; motion (240 ms modal, skeleton pulse 1.6 s, both stopped
|
|
under reduced motion).
|
|
|
|
## Manual verification
|
|
|
|
- **Real round trips against `task dev`** — Vitest only mocks `useApi`. Invite a real address and
|
|
confirm: the 201 row, the 409 texts for an existing account and a live pending invite, the
|
|
replacement of an expired invite, the 429 after exceeding `AUTH_RATE_LIMIT_MAX_ATTEMPTS` within
|
|
`AUTH_RATE_LIMIT_WINDOW_SECONDS`, and the list order after a reload.
|
|
- **Real invitation email** — needs real or sandboxed SMTP (`SMTP_HOST` and the related env
|
|
vars): the mail arrives and its link `{PUBLIC_URL}/invite/accept?c=...` carries the code. The
|
|
link will not resolve in the SPA, because the accept screen is out of scope.
|
|
- **Toast placement and stacking on a live app** — bottom right on desktop and above the pinned
|
|
button on mobile.
|
|
- **"Today" and the elevated row on a live app** — confirm the new row and the "Today" label
|
|
against the real clock, since visual:check skips this state.
|