# QA procedure: invitations Package: `test` (black-box). New fakes must have unique type names across the package: `mockInviteRepo` (implements `invite.Repository` with an in-memory slice and a `failSwitch`), plus a `fixedClock` only if the use case takes one. Reuse `mockUserRepo` and `stubHasher` (`user_test.go`) and `mockMailRepo` (`mail_test.go`; `SentEmails` entries expose `Address`, `Template`, `Data`; `fail.failNext("Send")`). ## Setup shared by every case 1. Call `validator.InitValidator()` once. 2. Use cases: `useCases.NewCreateInviteUseCase(userRepo, inviteRepo, &stubHasher{}, mailRepo, "https://cms.example.com")` and `useCases.NewListUsersAndInvitesUseCase(userRepo, inviteRepo)` (final names are the coder's; constructors take only domain interfaces). 3. Handler: `user.NewHandler(updateProfile, createInvite, listUsersAndInvites)` mounted on a fresh `chi.NewRouter()` mirroring `NewUserRouter`: `jwtauth.Verifier` + `jwtauth.Authenticator` with `jwtauth.New("HS256", []byte("test-secret"), nil)`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`, then `POST /invite` and `GET /`. `POST /invite` is also wrapped in `RequireOwner(getUser)`, placed before the limiter. For the rate-limit case wrap only `POST /invite` in `RequireOwner(getUser)` + `TrustedProxyRealIP(nil)` + `NewRateLimiter(2, time.Minute)`. Requests via `httptest`; jwt cookie built with the `user_id` and `token_version` claims as in `test/verified_account_required_http_test.go`. No live server, no real SQLite, no real SMTP. 4. Seed users: verified id 7 "marta" with `Role: owner`; unverified id 8 for the 403 cases. Time is real: build invites with `ExpiresAt`/`CreatedAt` relative to `time.Now()` and compare with a 5 s tolerance. ## POST /v1/user/invite 5. Body `{"email":"Sam.Oliveira@Renew.example"}`, cookie for 7 -> `201`. Decode into `map[string]json.RawMessage`: the key set is exactly `id`, `kind`, `email`, `status`, `created_at`, `expires_at`, with `kind`="invite", `email`="sam.oliveira@renew.example", `status`="pending"; none of `code`, `hashed_code`, `verification_code`, `password`. `inviteRepo` holds one invite: lower-case email, `InvitedBy==7`, `AcceptedAt` nil, `abs(ExpiresAt - (CreatedAt+72h)) < 5s`, stored code == `"hashed:"+rawCode` (stubHasher) where `rawCode` is taken from the mail data; the stored code is not equal to `rawCode`. 6. `mailRepo.SentEmails` has exactly one entry: `Address`, `Template=="invitation"`, data of the invitation mail type with `Host=="https://cms.example.com"`, `Email` the invited address, `InvitedBy=="marta"` and a uuid-shaped `VerificationCode`. 7. Inviter with `DisplayName:"Ines Duarte"` -> `InvitedBy=="Ines Duarte"`. Inviter with an empty display name -> username. 8. Table for 400 (`{"email":"sam.oliveira@renew"}`, `{"email":""}`, `{}`, `{"email":"not-an-email"}`, `not json`): status `400`, `inviteRepo` empty, `SentEmails` empty. 9. Existing user with email `"Jonas@Renew.example"`, invite `jonas@renew.example` -> `409`, body exactly the new apperr constant "This address already has an account.", no invite, no mail. This case pins that the match is case-insensitive. 10. Seed a pending invite (expires in 2 days) for the address -> `409`, body "There's already a pending invite for this address.", the seeded invite unchanged (same code hash and expiry), no mail. 11. Seed an expired invite (expired 1 h ago) -> `201`; `inviteRepo` has exactly one invite for the address, with a different code hash and an expiry about 3 days out; one mail sent. 12. `mailRepo.fail.failNext("Send")` -> `500`, body "Couldn't send the invite. Try again in a moment.", `inviteRepo` empty. Repeat the same request without re-arming -> `201`. With a seeded expired invite and a failing send -> `500` and no invite for that address remains. 13. Auth table (`POST`): no cookie -> `401`; cookie of unverified user 8 -> `403`; cookie with stale `token_version` -> `401`; cookie of a verified admin (seeded with `Role: admin`) -> `403`, plain text body exactly `apperr.OwnerRequiredErrorMessage`. Each asserts no invite and no mail. 14. Rate limit: limiter of 2 per minute, `RemoteAddr` `203.0.113.20:1234`, three valid POSTs -> `201`, `201`, `429`; third body is exactly `apperr.TooManyRequestsErrorMessage`, has `Retry-After`, and adds no invite or mail. `GET /` from the same address afterwards is not `429`. ## GET /v1/user/ 15. Seed per the feature scenario (three users, an expired, a pending and an accepted invite, with the stated `CreatedAt` values). `200`; decode as `[]map[string]json.RawMessage`. Assert length 5, order (pending sam, expired p.huber, ines, jonas, marta), key sets exactly as specified: `id`, `kind`, `username`, `display_name`, `email`, `role`, `status`, `created_at` for user items and `id`, `kind`, `email`, `status`, `created_at` for invite items, accepted invite absent, and none of the forbidden keys anywhere. Also assert the raw body string contains each accepted user's own email address. 16. Only user 7 -> array with exactly one `kind:"user"` item, status `accepted`. 17. Invite created 3 days + 1 minute ago -> `status:"expired"`; created 2 days ago -> `pending`; with `AcceptedAt` set -> not listed. 18. Auth table (`GET`): no cookie -> `401`; unverified -> `403`. ## Use-case level (no HTTP) 19. `CreateInvite` on a repository holding a user with the same email -> `errors.Is(err, invite.ErrEmailHasAccount)`; live pending invite -> `errors.Is(err, invite.ErrPendingInvite)` (sentinel names are the coder's). A mail failure wraps the mail repository error and the `Delete` of the invite is called (if `Delete` also fails, both errors are joined, as `CreateUser` does). ## Mailer (real embedded templates, no SMTP) 20. `mailer.MailRepository{}.Render("invitation", data)` with `Host:"https://cms.example.com"`, `VerificationCode:"abc-123"`, `Email:"lea@example.com"`, `InvitedBy:"Ines Duarte"` returns subject `"Ines Duarte invited you to RenewCMS"` and a body containing `https://cms.example.com/invite/accept?c=abc-123`, `lea@example.com` and `Ines Duarte`. An `InvitedBy` of `x` is HTML-escaped in body. Subjects for `verification` and `password-reset` are unchanged, and an unknown template still gets the default subject. 21. `internal/infrastructure/mailer/templates/invitation.html` is byte-identical to `docs/design/source/emails/invitation.html` (read both files and compare). ## Regression: registration (extends registration_gate_and_verification_email.qa.md) 22. With one verified user and a pending invite for `sam.oliveira@renew.example`, `POST /v1/auth/register` with that email -> `403`, body exactly `apperr.RegistrationClosedErrorMessage`, no user created, no mail sent. 23. Empty user repository -> `POST /v1/auth/register` still `200` with a `jwt` cookie and one `verification` mail, and `inviteRepo` untouched. `GET /v1/auth/registration` still reports closed when a verified user and a pending invite exist. ## Manual verification (not exercised by the Go tests) - Start the app once against a fresh SQLite file: the `invites` table exists (model registered in `AutoMigrate` in `api/dependecyInjection.go`) and inviting persists across a restart. - Send a real invitation through a real or sandboxed SMTP server: the subject line, the rendering in a mail client (light and dark) and that the link `{PUBLIC_URL}/invite/accept?c=...` carries the raw code. The screen the link opens is not designed and is out of scope, so the link will not resolve in the SPA yet. - SPA copy for the 429 ("Too many invites. Wait a moment, then try again.") is a note for the later SPA slice and is not tested here. The SPA keys list rows by kind and id, since a user and an invite can share a numeric id. - Layering check by review: use cases import only `internal/domain/*`; the mailer subject logic lives in `internal/infrastructure/mailer`.