99 lines
7.9 KiB
Markdown
99 lines
7.9 KiB
Markdown
# QA: Users row actions (Cancel invite, Renew invite)
|
|
|
|
## Implementation note for coder (informational, not a QA assertion)
|
|
|
|
No Go change. Toasts use useToast().add({ id, color, icon, title, description }):
|
|
- cancelled: id "invite-cancelled-{id}", color success, icon i-lucide-circle-check, title "Invite cancelled",
|
|
description "{email} can no longer use the link."
|
|
- renewed: id "invite-renewed-{id}", color success, icon i-lucide-circle-check, title "Invite renewed",
|
|
description "{email} has 3 days to accept the new link."
|
|
- renew failed: id "invite-renew-failed-{id}", color error, icon i-lucide-circle-x, title "Couldn't renew the invite",
|
|
description "Try again in a moment."
|
|
|
|
Cancel runs through ConfirmDialog (tone "danger", cancelLabel "Keep invite", confirmLabel "Cancel invite",
|
|
pendingLabel "Cancelling…"); its action throws an Error whose message is "Couldn't cancel the invite. Try again in
|
|
a moment." for any failure except 401/403 (global handling), so the dialog shows it. Remove the 10.5 % exception
|
|
from the "users" > Default state in web/visual/states.ts.
|
|
|
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
|
|
|
Mock useApi's `request` for GET /v1/user/, DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew. Mount
|
|
UsersPage inside DashboardShell with a verified owner session (id 1, "marta", role "owner") and a test router at
|
|
/users. Default fixture as in features/users_screen.qa.md. No test freezes the system date; the renewed row's
|
|
"Today" label is not asserted. Run layout cases at desktop and below-md widths (mock matchMedia).
|
|
|
|
1. Default actions: row 1 has "Cancel invite" (user-x), row 2 "Renew invite" (refresh-cw), Accepted rows have
|
|
none; at below-md the same buttons sit last in the cards.
|
|
2. Non-owners, Examples-driven: role "admin", role absent, role "owner"; assert presence or absence of both
|
|
buttons at both widths.
|
|
3. Cancel invite: confirm: click "Cancel invite" on row 1; assert dialog title, text with the address, "Keep
|
|
invite", "Cancel invite", and that `request` was not called with DELETE.
|
|
4. Keep invite: click it; assert the dialog closed, no DELETE, 5 rows.
|
|
5. Cancel invite: sending: unresolved DELETE; assert one call to DELETE /v1/user/invite/2 (no body), "Cancelling…",
|
|
"Keep invite" disabled, Escape and backdrop click do not close.
|
|
6. Cancel invite: failed, Examples-driven: ApiError(500), ApiError(500) with other text, ApiError(404),
|
|
NetworkError; assert the dialog stays open with the notice "Couldn't cancel the invite. Try again in a moment."
|
|
once (never the raw body), buttons re-enabled, 5 rows, no toast.
|
|
7. Cancel invite: retry: after 6, resolve 204 on the second click; assert two DELETE calls, dialog closed, row gone.
|
|
8. Cancel invite: done: resolve 204; assert dialog closed, row 1 gone, 4 rows in order, no second GET, toast
|
|
raised with the id, color, icon, title and description above.
|
|
9. 401 and 403 on DELETE and on renew: assert the global handling runs and no local notice or toast is added.
|
|
10. Renew invite: sending: unresolved POST; assert one call to POST /v1/user/invite/1/renew with no body, no
|
|
dialog, the row-2 button reads "Renewing…", is disabled, has the loader-circle icon and 50 % opacity class; row
|
|
1's "Cancel invite" is enabled; a second click sends nothing.
|
|
11. Only the busy row is busy: two Expired rows; renew one, assert the other button is enabled and usable.
|
|
12. Renew invite: done: resolve 200 with the response shape; assert "p.huber@gmail.com" is first, Pending, has the
|
|
--ui-bg-elevated class, keeps "Paul Huber", ends with "Cancel invite", "sam.oliveira@renew.example" second, one
|
|
row for the address, no second GET, and the toast (id, color, icon, title, description).
|
|
13. Renew invite: failed, Examples-driven: ApiError(500), ApiError(500) with other text, ApiError(404),
|
|
NetworkError; assert the error toast, the row still Expired in place, the button enabled again with the label
|
|
"Renew invite".
|
|
14. Renew retry: after 13, resolve 200; assert the success toast and the row on top.
|
|
15. Interplay: a row from a fresh invite (201) shows "Cancel invite"; cancelling a just-renewed row removes it and
|
|
raises "Invite cancelled".
|
|
16. Invite-only rule: assert no "Renew invite" on a Pending row and no "Cancel invite" on an Expired row.
|
|
|
|
## 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`. Layout (button size, position in the
|
|
sixth column and on the card, dialog geometry) is taken from the board frame, not components.md. The gate is hard:
|
|
every capture of every non-skipped state must be within 10 %, both viewports, both themes. Use the board's sample
|
|
data: the existing `usersFixture` (sam.oliveira@renew.example pending 27 Sep 2026; p.huber@gmail.com "Paul Huber"
|
|
expired 3 Aug 2026), session marta/owner. Add these 7 states to `users` in web/visual/states.ts:
|
|
|
|
- [ ] Cancel invite: confirm: steps click "Cancel invite" on the sam.oliveira row; dialog "Cancel this invite?" with
|
|
text naming sam.oliveira@renew.example, "Keep invite", "Cancel invite".
|
|
- [ ] Cancel invite: sending: mock `DELETE /v1/user/invite/2` status 204 with `hang: true`; steps open the dialog and
|
|
click "Cancel invite"; "Cancelling…" loading, "Keep invite" disabled.
|
|
- [ ] Cancel invite: failed: mock `DELETE /v1/user/invite/2` status 500; steps open the dialog and confirm; the
|
|
notice "Couldn't cancel the invite. Try again in a moment." inside the dialog, buttons enabled.
|
|
- [ ] Cancel invite: done: `{ path: '/users', skip: 'clock-sensitive' }` (per the user's request; the design does
|
|
not mark it clock-sensitive). Prints SKIP, never investigated or date-frozen. Vitest case 8 covers it.
|
|
- [ ] Renew invite: sending: mock `POST /v1/user/invite/1/renew` status 200 with `hang: true`; steps click "Renew
|
|
invite" on the p.huber row; only that button shows "Renewing…", loader-circle, 50 % opacity.
|
|
- [ ] Renew invite: done: `{ path: '/users', skip: 'clock-sensitive' }` ("Today"). Vitest case 12 covers it.
|
|
- [ ] Renew invite: failed: mock `POST /v1/user/invite/1/renew` status 500; steps click "Renew invite"; the error
|
|
toast "Couldn't renew the invite" / "Try again in a moment." is visible and the p.huber row is Expired with
|
|
"Renew invite". Keep the toast on screen for the capture; if the runner can't, stop and ask.
|
|
- [ ] Default: with the exception removed it passes at 10 % with the drawn buttons; if it fails, stop and ask (no
|
|
exception without approval).
|
|
- [ ] Not drawn, no visual state: non-owner rendering and the 404 fallback. Vitest only (cases 2, 6, 13).
|
|
- [ ] Not covered by screenshots: keyboard order (Tab reaches each row action; in the dialog focus starts on "Keep
|
|
invite", is trapped, Esc closes it except while Cancelling…, focus return after the dialog closes); hover and
|
|
active on the buttons; motion (modal 240 ms, toast enter and exit), stopped under reduced motion.
|
|
|
|
## Manual verification
|
|
|
|
- Real round trips against `task dev`, as owner: cancel a pending invite (row disappears, the old link at
|
|
/invite/accept?c=... answers "This invite link doesn't work."), renew an expired one (row moves to top with
|
|
"Today", old link dead, new link works), and reload to confirm the order matches the API.
|
|
- Real invitation email on renew needs real or sandboxed SMTP (`SMTP_HOST` and related vars): confirm the new mail
|
|
arrives with the new link and the old one no longer works.
|
|
- Failure paths with the API stopped or SMTP failing: the renew toast and the cancel dialog notice appear, and a
|
|
retry succeeds.
|
|
- Admin session against `task dev`: no row actions are shown.
|
|
- Toast placement and stacking on desktop (bottom right) and above the pinned "Invite user" button on mobile.
|
|
- Focus after a successful cancel (the row is gone) lands somewhere sensible.
|
|
- "Today" and the elevated row on a live app after Renew, since visual:check skips it.
|