8.0 KiB
QA procedure: account_delete
Package: test (black-box), extending test/user_test.go (mockUserRepo, stubHasher), test/mail_test.go (mockMailRepo, failSwitch) and the existing role, author, api-key and audit fakes. Reuse them and keep new type names unique.
Test infrastructure additions needed first
- A fake of the atomic domain operation behind
DeleteOwnAccountUseCasethat mutates the in-memory fakes together.- It honours
fail.triggerat each step ("DeleteAPIKeys", "ScrubAuthor", "AnonymiseEvents", "DeleteUser"). - On any failure it restores a snapshot, so rollback is observable.
- It honours
- The role fake: seed built-in Owner, Admin, Writer and a custom role with all admin permissions. Add a
CountHolders-style method (or whichever method the coder adds to the domain interface) returning verified holders only. - The audit fake: record events and support the anonymise method; assert the rewritten events by id.
- The session revocation: reuse the
authtokenrevocation fake; record revoked ids.
Setup shared by every case
- Call
validator.InitValidator()once. - Build
useCases.NewDeleteOwnAccountUseCase(...)over the fakes,stubHasher,mockMailRepo, a clock,publicURL "https://cms.example.com"and the audit fake. - Handler checks mount
DELETE /v1/user/meon a freshchi.NewRouter()with theNewUserRoutersession chain and the 5 per 15 minutes limiter. Also mountDELETE /{id}behind its permission middleware, to prove the routing order. Mint jwt cookies withuser_idandtoken_version. - Seed marta (id 7, Writer, "hashed:limewash2026"), jonas (2, Admin), ines (3, Owner). No live server, real SMTP or real SQLite.
Use-case checks
- Happy path for a Writer. Assert:
- The user is gone, keys 1 and 2 are gone.
- The author row is scrubbed (name, slug
deleted-user-<id>, bio "", avatar/website nil, links empty, user_id nil) when it credits 3 articles. - The author is deleted when it credits none.
- Articles, entries and images are byte-for-byte unchanged (
reflect.DeepEqualbefore and after). - Her 12 events are anonymised and the other users' events are untouched.
- The session id is revoked.
- Exactly one
account-deletedmail goes to the old address, with datauseCases.MailAccountDeletedData{Host}and no other field. - Exactly one
user.delete_accountevent, with actor and target name "Deleted user" and no@in the params.
- Password. A wrong password returns
user.ErrWrongPassword(errors.Is), changes nothing, sends nothing and records one failed security event. - Last-role matrix. Table-driven from the feature (9 rows):
- Owner/Admin blocked rows return
user.ErrLastAdmincarrying the role label "Owner" or "Admin" (assert witherrors.Ason whatever the coder exports). - Unverified holders do not count.
- A custom all-permission role does not count and is not blocked.
- Allowed rows delete.
- Owner/Admin blocked rows return
- Concurrent role change. The fake changes a holder between the check and the operation. Assert
ErrAccountNotDeletableand an untouched state. - Rollback. For each step failure in case 1, assert that all fakes equal their pre-state, no mail is sent, no revocation is recorded and the error is returned.
- Mail failure with the log captured. Assert nil error, the user gone, and a log line naming the failed send.
- Order of checks. A wrong password plus a last-Owner caller returns
ErrWrongPassword, not last_admin (no role probing without the password).
HTTP checks
- Success. Assert 204, an empty body, and a
Set-CookieforjwtwithMax-Age=0or an expired date. A follow-upGETwith the old cookie through the real middleware chain gives 401. - Refusals table of the feature: status and exact body for each row. For the 400 rows, compare to the
validator.Validate.Structmessage. Assert the fakes are unchanged. - Last role: 409, body
last_admin, headerX-Last-Roleequal toOwnerorAdmin. account_not_deletablegives 409 with that body.- Rate limit. Make 5 attempts (mix wrong-password and blocked), then assert the sixth is 429 with
Retry-Afterand that no state changed. - Auth gates: 401, API key 403 and unverified 403, with exact bodies.
- Routing.
DELETE /v1/user/mereaches the new handler, neverremoveHandler.DELETE /v1/user/7?expected_role_id=3still behaves asfeatures/users_row_actions.qa.mdsays. - Mail rendering. Render
account-deletedwith the realMailRepository.Render. Assert the subject, no@, no username, no button, no leftover{{.
Persistence (real in-memory SQLite, same models as api/dependecyInjection.go)
- One test runs the real atomic operation. Assert the user, the keys, the scrub-or-delete of the author, the audit anonymisation, a forced mid-way failure leaving everything intact, and the portability of the SQL (no driver-specific syntax). If a real-DB test already exists for MySQL/PostgreSQL (
task test:mysql/test:postgres), run the same test there.
Vitest (SPA)
Strings live in messages.ts in English and French under the same keys.
I18n checks (extend the existing web/src/i18n/*.spec.ts style, e.g. spa.spec.ts):
- Key parity: every new key exists in both
enandfr, with no empty value and the same placeholders. - French render: for the Danger zone, the dialog, each Blocked variant and the Login notice with
setLocale('fr'), assert the French text is shown and no English string remains; switchingen,fr,enrestores the English copy. - The confirmation word stays the literal "DELETE" in both languages, and the French dialog label tells the user to type it.
- English values equal the design copy verbatim.
Flagged assumption: the French wording is authored by the coder because docs/design has none. French strings to author, for the user's review: the Danger zone title, text and button; the dialog title, text, the three list items, the "Your articles and media stay" heading and text, "Export my data first", the two field labels, "Keep my account", "Delete my account", "Deleting…"; the wrong-password, 429 and server errors; the three Blocked variants (Owner, administrator, cannot be deleted) with their buttons; and the Login notice title and text.
| State | Mocked /v1 |
|---|---|
| Danger zone, Confirm: empty/typing/ready | none |
| Wrong password (403) | DELETE /user/me 403 "wrong_password" |
| Deleting | DELETE pending |
| Rate limited (429), Server error | 429; 500 and a rejected fetch |
| Blocked: last administrator | 409 "last_admin" + X-Last-Role: Admin |
| Blocked: last Owner | 409 "last_admin" + X-Last-Role: Owner |
| Blocked: cannot be deleted | 409 "account_not_deletable" |
| Success | 204; assert no POST /auth/logout, session.clear(), router at /login?reason=account-deleted |
Also assert:
- Confirm enablement for "DELETE", "delete", "DELETE ", "".
- A blocked dialog ignores Escape and the backdrop and has no close button.
- The Login notice appears for
reason=account-deletedonly, and the query is gone after it is consumed. - Typed values are kept on 429 and 500.
- The "Deleted user" fallback where a creator is missing from the lookup.
Visual
task visual:check -- account and -- auth.
- Board data to reuse: password mask
••••••••••, the typed word "DEL" in Confirm: typing and "DELETE" elsewhere, and the Auth board's "Account deleted" notice. - States: all 10 delete states of the Account board and "Account deleted" on the Auth board. The Owner variant has no frame.
- None is clock-sensitive.
Manual verification
- Real SMTP: the account-deleted mail arrives once at the old address, and the link-free rendering in a real client.
- Delete a real account in a browser against a real database file: log back in as another user and check an article byline reads "Deleted user", and Activity shows "Deleted user".
- On the embedded frontend, check that after deletion
/login?reason=account-deletedshows the notice once. - Keyboard and focus: the dialog focus trap, the focus on the cleared password after 403, Tab order in the blocked dialog.
- MySQL and PostgreSQL via
task test:mysql/task test:postgres.