128 lines
7.9 KiB
Markdown
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.
|