Feature: A verified user can set an optional display name that replaces their username for display only As one of several admins with equal access to a RenewCMS workspace I want to give my account an optional display name, up to 60 characters, and have the API return it wherever my own account is returned So that people are named by a human name instead of a username, while logging in still uses the username and no secret field ever reaches a response Background: Given internal/domain/user gains the field DisplayName, json "display_name", with no gorm tag, and the sentinel user.ErrInvalidDisplayName And user.Repository gains UpdateDisplayName(userId uint32, displayName string) (User, error), returning user.ErrUserNotFound when the user does not exist And the persistence model stores the display name in a column whose default is the empty string, so rows created before this slice still load with DisplayName "" And a new use case, UpdateUserProfile, exposes UpdateDisplayName(userId uint32, displayName string) (user.User, error) and depends only on user.Repository And UpdateUserProfile trims the value with strings.TrimSpace, then counts what remains with utf8.RuneCountInString (runes, not bytes), returns user.ErrInvalidDisplayName without calling the repository when the count is above 60, and otherwise hands the trimmed value to UpdateDisplayName; an empty or whitespace-only value therefore stores "" and clears the name And api/pkg/apperr defines DisplayNameErrorMessage as "Display name must be 60 characters or fewer." And PATCH /v1/user/me is served by a new handler in api/handlers/user/, mounted by a new /v1/user router in api/router.go inside a group carrying jwtauth.Verifier, jwtauth.Authenticator, middleware.RequireCurrentToken and middleware.RequireVerifiedUser, in that order, so an unauthenticated caller is refused with 401, a stale token with 401 apperr.UnauthorizedErrorMessage, and an unverified caller with 403 apperr.VerificationRequiredErrorMessage before the handler runs And the request body is JSON {"display_name": }; the request struct field is a *string tagged validate:"required" and is checked with validator.Validate.Struct; a body that is not valid JSON, a display_name that is not a string, or a missing or null display_name answers 400 And the handler maps user.ErrInvalidDisplayName to 400 with body apperr.DisplayNameErrorMessage, user.ErrUserNotFound to 401 apperr.UnauthorizedErrorMessage, and any other error to 500 "Internal server error", all as plain text from http.Error And the public user struct and its converter move out of api/handlers/auth into a shared package under api/pkg, imported by both the auth and user handlers, so neither handler imports the other And on success the response is 200 and the body is the updated user in that public shape: exactly id, username, email, is_verified, created_at, updated_at and display_name And GET /v1/auth/me and POST /v1/auth/verify use that same public shape, so display_name is present in both, as "" when unset And password, password_reset_code, verification_code, verification_expiration, password_reset_expiration and token_version never appear in any response (features/user_response_field_hygiene.feature) And POST /v1/auth/login still authenticates with the username only; display_name is neither accepted nor matched there And changing the display name does not change token_version, so existing sessions stay valid Scenario: A verified user sets a display name Given a user repository containing user id 7, username "marta", email "marta@renew.example", is_verified true, DisplayName "", TokenVersion 0 And a valid jwt cookie for user id 7 encoding token_version 0 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta Alves"} Then the response status is 200 And the response body is a JSON object with exactly the keys id, username, email, is_verified, created_at, updated_at and display_name And the response body's id is 7, username "marta", display_name "Marta Alves" And the user repository now holds DisplayName "Marta Alves" for user id 7 And the user repository still holds username "marta" Scenario Outline: The value is trimmed before it is stored and returned Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "" And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": } Then the response status is 200 And the response body's display_name is And the user repository holds DisplayName for user id 7 Examples: | sent | returned | | " Marta Alves " | "Marta Alves" | | "\tMarta Alves\n" | "Marta Alves" | | "Marta Alves" | "Marta Alves" | Scenario Outline: An empty or whitespace-only value clears the display name Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "Marta Alves" And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": } Then the response status is 200 And the response body's display_name is "" And the response body's username is "marta" And the user repository holds DisplayName "" for user id 7 Examples: | sent | | "" | | " " | | "\t \n" | Scenario Outline: The limit is 60 Unicode characters, counted as runes Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "" And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and a display_name of repetitions of Then the response status is And Examples: | count | unit | status | effect | | 60 | "a" | 200 | the repository holds that 60-character value | | 61 | "a" | 400 | the body is apperr.DisplayNameErrorMessage and DisplayName is still "" | | 60 | "😀" | 200 | the repository holds that 60-rune value (240 bytes) | | 61 | "😀" | 400 | the body is apperr.DisplayNameErrorMessage and DisplayName is still "" | | 60 | "é" | 200 | the repository holds that 60-rune value (120 bytes) | Scenario: The limit applies after trimming, not before Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "" And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and a display_name made of 5 spaces, 60 "a" characters and 5 spaces (70 characters in total) Then the response status is 200 And the response body's display_name is the 60 "a" characters Scenario: Control characters inside the name are kept Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "" And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Ma\nrta"} Then the response status is 200 And the response body's display_name is "Ma\nrta" Scenario: A rejected value leaves the existing display name untouched Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "Marta Alves" And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and a display_name of 61 "a" characters Then the response status is 400 And the repository's UpdateDisplayName is never called And the user repository still holds DisplayName "Marta Alves" for user id 7 Scenario Outline: A malformed body is refused with 400 Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "Marta Alves" And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and Then the response status is 400 And the repository's UpdateDisplayName is never called And the user repository still holds DisplayName "Marta Alves" for user id 7 Examples: | body | | the raw text not json | | {} | | {"display_name": null} | | {"display_name": 42} | | {"displayName": "Marta"} | Scenario: The password, token version and login username are unaffected by a display name Given a user repository containing user id 7, username "marta", is_verified true, a stored password hash, DisplayName "", TokenVersion 0 And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta Alves"} Then the response status is 200 And the stored password hash for user id 7 is unchanged And the stored TokenVersion for user id 7 is unchanged And the same jwt cookie still gets 200 from GET /v1/auth/me Scenario: GET /v1/auth/me returns the display name once set Given a user repository containing user id 7, username "marta", is_verified true, DisplayName "Marta Alves" And a valid jwt cookie for user id 7 When the client sends GET /v1/auth/me with that cookie Then the response status is 200 And the response body has display_name "Marta Alves" and username "marta" Scenario: GET /v1/auth/me returns an empty display_name, not a missing key, when none is set Given a user repository containing user id 8, username "bob", is_verified false, DisplayName "" And a valid jwt cookie for user id 8 When the client sends GET /v1/auth/me with that cookie Then the response status is 200 And the response body is a JSON object with exactly the keys id, username, email, is_verified, created_at, updated_at and display_name And the response body's display_name is "" Scenario: Logging in still uses the username only Given a user repository containing verified user "marta" with a known password and DisplayName "Ines Duarte" When the client sends POST /v1/auth/login with username "Ines Duarte" and the correct password Then the response status is 403 And the response body is apperr.LogsErrorMessage When the client sends POST /v1/auth/login with username "marta" and the correct password Then the response status is 200 Scenario: An unverified user is refused Given a user repository containing user id 8, username "bob", is_verified false, DisplayName "" And a valid jwt cookie for user id 8 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Bob"} Then the response status is 403 And the response body is the apperr.VerificationRequiredErrorMessage text And the user repository still holds DisplayName "" for user id 8 Scenario: No jwt cookie is refused Given a user repository containing user id 7, username "marta", is_verified true When the client sends PATCH /v1/user/me with no jwt cookie and body {"display_name": "Marta"} Then the response status is 401 And the repository's UpdateDisplayName is never called Scenario: A stale jwt is refused Given a user repository containing user id 7, username "marta", is_verified true, TokenVersion 1 And a jwt cookie for user id 7 encoding token_version 0 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"} Then the response status is 401 And the response body is the apperr.UnauthorizedErrorMessage text And the repository's UpdateDisplayName is never called Scenario: A jwt minted for a since-deleted account is refused Given a user repository containing no user with id 999 And a valid jwt cookie for user id 999 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"} Then the response status is 401 And the response body is the apperr.UnauthorizedErrorMessage text Scenario: A repository failure while saving answers 500 Given a user repository containing user id 7, username "marta", is_verified true And the user repository fails on its next call to UpdateDisplayName And a valid jwt cookie for user id 7 When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"} Then the response status is 500 And the response body is the text "Internal server error" Scenario: The user is deleted between authentication and the save Given a user repository whose UpdateDisplayName returns user.ErrUserNotFound for user id 7 And a valid jwt cookie for user id 7 that passes the middleware When the client sends PATCH /v1/user/me with that cookie and body {"display_name": "Marta"} Then the response status is 401 And the response body is the apperr.UnauthorizedErrorMessage text Scenario Outline: The use case enforces the same rule without HTTP Given a fake user repository containing user id 7 with DisplayName "Old" When UpdateUserProfile.UpdateDisplayName is called for user id 7 with Then the result is Examples: | input | outcome | | " Marta " | the user with DisplayName "Marta" | | "" | the user with DisplayName "" | | " " | the user with DisplayName "" | | 60 "😀" runes | the user with those 60 runes | | 61 "😀" runes | user.ErrInvalidDisplayName (errors.Is), repository not called | | "Marta" for unknown user id 99 | user.ErrUserNotFound (errors.Is) |