Files
RenewCMS/features/user_display_name.qa.md

128 lines
7.9 KiB
Markdown

# QA procedure: user_display_name
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`, `stubHasher`) and
following the router wiring pattern in `test/get_current_user_http_test.go`. No live server, no real
SQLite file, no real SMTP. Files: `test/user_display_name_test.go` (use case and HTTP).
## Test infrastructure additions needed first
1. Add `UpdateDisplayName(userId uint32, displayName string) (user.User, error)` to `mockUserRepo`:
call `m.fail.trigger("UpdateDisplayName")` first, return `user.ErrUserNotFound` when the id is
absent, otherwise store `DisplayName` on the map entry and return it. Add fields
`updateDisplayNameCalls int` and `lastUpdateDisplayName string`.
2. Use `failNext("UpdateDisplayName")` for the 500 case. For the "deleted between auth and save"
case, add `failNextWith("UpdateDisplayName", user.ErrUserNotFound)` (as planned in
`features/article_title_rules.qa.md` step 1), or seed the user, then delete it from the fake after
the middleware step via a hook.
3. Any other type implementing `user.Repository` in `test/` must gain the method so the package
compiles.
## Setup shared by every HTTP case
4. Call `validator.InitValidator()`.
5. `getUser := useCases.NewGetUserUseCase(userRepo)`, `update :=
useCases.NewUpdateUserProfileUseCase(userRepo)`, `userHandler := user.NewHandler(update)` (import
`RenewCMS/api/handlers/user` under an alias that does not collide with the domain `user`
package), `meHandler := auth.NewMeHandler(getUser)`.
6. Fresh `chi.NewRouter()` mirroring `api/router.go`, with `token := jwtauth.New("HS256",
[]byte("test-secret"), nil)`:
```go
r.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser))
r.Patch("/me", userHandler.UpdateMe)
})
r.Route("/v1/auth", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me)
})
```
Add the public `/v1/auth/login` route (real `auth.NewLoginHandler` over the fakes, as in the
existing login tests) for the login case. The handler method names above are indicative; use
whatever the handlers expose.
7. Mint cookies with `token.Encode(map[string]any{"user_id": uint32(id), "token_version":
uint32(v)})` set as the `jwt` cookie.
8. Decode success bodies into `map[string]json.RawMessage` (not `user.User`) so key sets can be
asserted. Error bodies are plain text from `http.Error` and carry a trailing `"\n"`.
## Use-case checks (fake repo, no HTTP)
9. `useCases.NewUpdateUserProfileUseCase(repo)` with user id 7 seeded, `DisplayName: "Old"`:
- `" Marta "` -> `DisplayName "Marta"` returned and stored.
- `""` and `" "` -> `DisplayName ""` stored (cleared).
- `strings.Repeat("😀", 60)` -> success. `strings.Repeat("😀", 61)` -> `errors.Is(err,
user.ErrInvalidDisplayName)`, `updateDisplayNameCalls == 0`, stored value still `"Old"`.
- Unknown id 99 -> `errors.Is(err, user.ErrUserNotFound)`.
- Repository failure via `failNext("UpdateDisplayName")` -> the error is returned unchanged.
## HTTP checks
10. Seed user 7 (`marta`, `marta@renew.example`, `IsVerified: true`, `DisplayName: ""`,
`TokenVersion: 0`) with a non-empty `Password` hash. `PATCH /v1/user/me`
`{"display_name":"Marta Alves"}` -> `200`. Body keys are exactly `{id, username, email,
is_verified, created_at, updated_at, display_name}`. `display_name` is `"Marta Alves"`,
`username` is `"marta"`. The repo holds `"Marta Alves"`. The stored `Password` and
`TokenVersion` are unchanged. The same cookie then gets `200` from `GET /v1/auth/me`.
11. Trim table: `" Marta Alves "` and `"\tMarta Alves\n"` -> `"Marta Alves"`; `"Marta Alves"`
keeps its interior spaces.
12. Clear table (seed `DisplayName: "Marta Alves"`): `""`, `" "`, `"\t \n"` -> `200`, response and
repo `display_name ""`, `username` still `"marta"`.
13. Length table (seed `""`): 60 x `"a"` -> `200`; 61 x `"a"` -> `400` with body
`apperr.DisplayNameErrorMessage + "\n"`; 60 x `"😀"` -> `200`; 61 x `"😀"` -> `400`; 60 x `"é"`
-> `200`. Assert `updateDisplayNameCalls == 0` on every `400` and that the stored value is
unchanged.
14. Trim-before-count: `strings.Repeat(" ", 5) + strings.Repeat("a", 60) + strings.Repeat(" ", 5)`
-> `200`, returned value is the 60 `"a"`.
15. Control characters: `{"display_name":"Ma\nrta"}` -> `200`, returned and stored value is
`"Ma\nrta"`.
16. Malformed table (seed `DisplayName: "Marta Alves"`): raw text `not json`, `{}`,
`{"display_name":null}`, `{"display_name":42}`, `{"displayName":"Marta"}` -> each `400`,
`updateDisplayNameCalls == 0`, stored value unchanged. Do not assert an exact body for these.
17. `GET /v1/auth/me` for user 7 with `DisplayName "Marta Alves"` -> `200`, `display_name` is
`"Marta Alves"`. For user 8 (`IsVerified: false`, `DisplayName ""`) -> key set is exactly the
seven keys and `display_name` is `""`, not absent.
18. Login: seed verified user `marta` with a stub-hashed password and `DisplayName: "Ines Duarte"`.
`POST /v1/auth/login` with username `"Ines Duarte"` -> `403`, body
`apperr.LogsErrorMessage + "\n"`. With username `"marta"` -> `200`. Confirm the login handler's
real status for an unknown username before asserting `403`.
19. Unverified user 8 with matching cookie -> `PATCH` -> `403`, body
`apperr.VerificationRequiredErrorMessage + "\n"`, `updateDisplayNameCalls == 0`.
20. No cookie -> `401` (do not assert the body; produced by `jwtauth.Authenticator`),
`updateDisplayNameCalls == 0`.
21. User 7 with `TokenVersion: 1`, cookie encoding `token_version 0` -> `401`, body
`apperr.UnauthorizedErrorMessage + "\n"`, `updateDisplayNameCalls == 0`.
22. Cookie for user 999 that does not exist -> `401`, same body.
23. `failNext("UpdateDisplayName")` (generic error) with a valid verified user -> `500`, body
`"Internal server error\n"`.
24. Repository returns `user.ErrUserNotFound` from `UpdateDisplayName` only (middleware passes) ->
`401`, body `apperr.UnauthorizedErrorMessage + "\n"`.
25. Sweep for secrets: on every `200` PATCH body and on `GET /v1/auth/me`, reuse
`assertNoForbiddenKeys` from `test/user_response_field_hygiene_test.go`. Add `token_version` to
that helper's forbidden keys if it does not check it yet.
## Edits to existing tests required by the six to seven field change
26. `test/user_response_field_hygiene_test.go` (`expectedKeys` in the verify test, around line 127):
add `"display_name"`. The count check then expects seven. The seeded user 7 has no display
name, so also assert `display_name` decodes to `""`.
27. `test/get_current_user_http_test.go` decodes into the public user struct; update its import to
the shared package. Step 17 is the authoritative shape check for `/me`.
## Manual verification (not exercised by the Go test)
- Start an existing dev SQLite file created before this slice, boot the server so `AutoMigrate`
runs, and confirm `GET /v1/auth/me` for the pre-existing user returns `display_name: ""` and that
login still works. Then `PATCH`, restart, and confirm the value persisted.
- Confirm by inspection that the `internal/domain/user` struct has no `gorm` tag on `DisplayName`,
the GORM tag with the empty-string default sits in `persistence/models`, `persistence/mappers`
maps both directions, and the model list in `api/dependecyInjection.go` needs no new entry (same
model, new column).
- Confirm that the new handler package constructs nothing and only receives `UpdateUserProfile`
via `NewHandler(...)`, that `dependecyInjection.go` wires the use case into the `*UseCases`
container, and that neither handler package imports the other.
- No SMTP or real embedded frontend is involved. The SPA is out of scope.