# QA procedure: Remove a user (roles sync pass 6) Spec: features/roles_sync_remove_user.feature. This slice touches Go and the SPA. Go: `task test` (go test -tags nofrontend ./test/... -count=1), `task lint:go`, `gofmt -l`, `go vet -tags nofrontend`, and the mutation scope of `task mutate` for RemoveUser.go. SPA: Vitest + Vue Test Utils under `task test:web`. No live server, no real SQLite file in the Go tests, no real SMTP. ## Honesty rules 0. First run `task test` and `task test:web` on the unmodified tree and record exit codes and failing specs (known Go failure: TestRouterRoutesAreInTheCatalogue; it must pass at the end because the route is added to the catalogue). 1. A runner that cannot execute: its items are NOT RUN, never passing. 2. Report each new or edited test and which already failed in the baseline. 3. The removal dialog, success toast, inline 500 error, own-row reason and the user.remove success sentence are not in the design: tests check behaviour (the element exists, opens, closes, is disabled) and never assert their text. They are listed as design gaps. 4. No spec freezes the date; fixed `created_at` strings. ## Automated (Go, httptest, in-memory fakes) in test/ Build the handler with `NewHandler(...)` (or the named constructor of the user handler) over RemoveUserUseCase with in-memory fakes of user.Repository (extended with DeleteIfRole, which can be told to answer ErrRoleChanged or an error), role.Repository, contenttype.Repository and an audit recorder. Mount on a fresh `chi.NewRouter()` mirroring api/router.go: the route inside `jwtauth.Verifier` + `Authenticator` with a test `jwtauth.New("HS256", …)` and the require(users.remove) middleware built as in api/router.go. Call `validator.InitValidator()`. Unique fake names in package test. | Test | Steps and assertions | |---|---| | TestRemoveUserAnswers | the table of the feature (15 rows): status, body, user still listed or gone, X-Escalation-Key and X-Missing-Permission headers, 401 without cookie | | TestRemoveUserCheckOrder | stale role beats role_locked and escalation; own_role beats stale; missing_permission is answered before the target lookup (the fake counts Get calls) | | TestRemoveUserExpectedRoleIDValidation | absent, 0, non-numeric, negative: 400 invalid_role; bad id: the uint32 message | | TestRemoveUserRaceAnswers409 | the fake changes the stored role between Get and DeleteIfRole: 409, user present, no event | | TestRemoveUserWriteFailure | non-refusal error: 500, user present, one failed user.remove event | | TestRemoveUserAudit | success event: action user.remove, category users, target {user, id, username}, actor the caller; refusals record nothing | | TestRemoveUserRevokesAccess | after removal, a request with that user's cookie answers 401, and an API key of that user answers 401; another user's cookie and key still work | | TestRemoveUserLeavesContent | the removed user's articles, entries and invites remain with created_by unchanged | | TestRemoveUserOnlyAcceptedUsers | an invite id is not a user: 404 user_not_found; invites stay cancellable through DELETE /v1/user/invite/{id} | | TestRemoveUserLayering | internal/domain/user imports no gorm, chi or infrastructure; the handler imports no persistence; the use case depends on domain interfaces only (extend the existing layering test) | | TestRouterRoutesAreInTheCatalogue (existing) | passes: DELETE /v1/user/{id} is listed in routesNotYetInTheCatalogue (the Integration board has no card for it yet) | | persistence level (only if the suite has an in-memory persistence test; otherwise MANUAL item 5) | DeleteIfRole deletes when the role matches, answers ErrRoleChanged when it differs and ErrUserNotFound when absent, on SQLite and, with `task test:mysql` / `task test:postgres`, on the other drivers (SQL must stay portable) | Mutation scope: `MUTATE_FILES=RemoveUser.go MUTATE_RUN='TestRemoveUser' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`. docs/api.md and docs/api*.md document the route, its query parameter, the order of checks, the 409 and what a removal revokes and leaves. ## Automated (Vitest) | Spec | Cases | |---|---| | composables/__tests__/useUsers.spec.ts (extend) | removeUser sends DELETE /v1/user/{id}?expected_role_id=N, resolves on 204; refusal mapping of user_role_changed, user_not_found, escalation, role_locked, own_role, missing_permission, invalid_role | | views/__tests__/UsersRowActions.spec.ts (extend) | "Remove user" per grant (the table of the feature), owner row disabled with the design reason, own row disabled, absent for invites, a menu with only "Remove user", "Send password reset" still absent, disabled on a role the caller cannot cover | | components/__tests__/RemoveUserDialog.spec.ts (new) | opens on the action, names the member, Cancel closes, confirm sends the request with the role id of the list item, in-flight lock, 204 closes and removes the row, each answer of the table (409 warning toast with Reload that refetches and dismisses, 404 refresh and existing toast, 403 shared refusals, 500 inline error keeps the dialog); texts of the dialog and success toast are not asserted | | views/__tests__/UsersView.spec.ts (extend) | the row disappears after removal without a full reload, the count line follows | | lib/__tests__/activitySentence.spec.ts (extend) | user.remove failed "Failed to remove Ines Duarte" EN and FR; success is covered with the coder's wording, flagged as a gap | | data/__tests__/apiCatalogue.spec.ts (extend) | the catalogue does not contain DELETE /v1/user/{id}; "Users and invites" still has 6 routes | | i18n/roleMessages.spec.ts (extend) | the "User not removed" keys exist in EN and FR with matching placeholders | Mocks to reuse: Marta Alves (Owner), Jonas Weber (Admin, jonas@renewbuild.example), Ines Duarte (Writer), Lea Martin (Content lead), a pending and an expired invite; access from ownerAccess() / adminAccess() / customAccess(). ## Visual (task visual:check) `task visual:check -- roles` for: "Users: Row menu" (menu with Remove user), "Users: Owner row menu" (Remove user disabled), and the group 12 toast "User not removed: member's role changed first (409)". Board sample data: Ines Duarte, the Owner row, the toast copy of the frame. The removal dialog has no board frame, so no visual state exists for it and none is invented. Light, dark, desktop, mobile within 10 %. ## Manual checks 1. Two browsers: an owner changes Ines Duarte's role while an admin has the removal confirmation open, then the admin confirms: the toast "User not removed" shows and the list reloads with the new role. 2. A removed user's open browser tab: the next action answers 401 and returns to the login; their API key answers 401. 3. Remove a user who owns articles; the articles still show their author correctly (see Q-RM2). 4. Keyboard: menu item reachable, dialog focus order, Escape blocked while sending, focus returns to the row menu. 5. Upgrade check on a real SQLite file and, with `task test:mysql` / `task test:postgres`, the other drivers: DeleteIfRole compare-and-swap. 6. Mobile 390 px: card list menu, 44 px targets; FR; dark. 7. `task lint:go`, `gofmt -l`, `go vet -tags nofrontend`, `task lint:web`, `task test`, `task test:web` all green. ## Design gaps and backend notes - G1 The design has the menu item "Remove user" and the 409 toast, but no confirmation dialog, no success toast, no 500 copy, no reason for the own row. They are open question Q-RM1. - G2 The design has no success sentence for user.remove; "Removed {0}" / "{0} retiré" is the coder's proposal. - G3 The own_role refusal copy "You can't change your own role" is reused for a removal; a removal-specific sentence is not in the design. - G4 The design says the users group has 6 routes; with this route it has 7. - Backend change (new): DELETE /v1/user/{id}?expected_role_id=N, the DeleteIfRole repository method, ActionUserRemove, RemoveUserUseCase, handler method, route, DI wiring, docs.