diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 9c2afd7..0a195c3 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -69,18 +69,17 @@ function unauthorized(c: Context) { const DEFAULT_USERNAME_CHANGES = 1 /** - * Username-change result envelope: `{ success, error, value }`. On success `value` is - * the updated account; on a refusal `error` carries the message and `value` is an empty - * string. + * Username-change result envelope: `{ success, error, value }`, always HTTP 200. + * On success `value` is the updated account; on error `error` carries the message + * and `value` is an empty string. * - * A refusal is a 400. The body shape is unchanged — anything reading `error` still - * works — but it used to come back at HTTP 200, which meant a caller keying off the - * status read every refusal as a success. That envelope-at-200 was the reference's - * (`RecNet`) convention and is kept by `POST /account/create`; here it was traded for a - * status a client can actually branch on. + * The envelope-at-200 is the reference's (`RecNet`) convention — a refusal is a + * successful call that answers "no", and the player-facing sentence rides in `error`. + * `POST /account/create` does the same. This was briefly a 400 so a caller could branch + * on the status; it isn't, because that's not what the real service does. */ function usernameResult(c: Context, error = '', value: unknown = '') { - return c.json({ success: error === '', error, value }, error === '' ? 200 : 400) + return c.json({ success: error === '', error, value }) } /** Read a single string field from a form-urlencoded / multipart body. */ @@ -461,8 +460,7 @@ const app = new Hono() ].join(' '), security: AUTHED, responses: { - 200: json(UsernameResult, 'The updated account, in the result envelope'), - 400: json(UsernameResult, 'Refused — `error` carries the reason, `value` is ""'), + 200: json(UsernameResult, 'Result envelope (success or a validation error)'), 401: UNAUTHORIZED_RESPONSE, }, }), diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index 9eba46d..143df0b 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -220,9 +220,8 @@ describe('auth-gated endpoints', () => { ...form({ username: 'Coach' }), headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' }, }) - // A refusal is a 400 carrying the same { success, error, value } envelope. It used - // to be HTTP 200, which read as a success to anything branching on the status. - expect(res.status).toBe(400) + // Business errors are HTTP 200 with the { success, error, value } envelope. + expect(res.status).toBe(200) const body = (await res.json()) as { success: boolean; error: string; value: string } expect(body.success).toBe(false) expect(body.error).toMatch(/already taken/i) @@ -261,7 +260,7 @@ describe('auth-gated endpoints', () => { ...form({ username: 'coachy' }), headers, }) - expect(blocked.status).toBe(400) + expect(blocked.status).toBe(200) const blockedBody = (await blocked.json()) as { success: boolean; error: string } expect(blockedBody.success).toBe(false) expect(blockedBody.error).toMatch(/no username changes/i) @@ -509,8 +508,9 @@ describe('name, email and bio validation', () => { headers, }) // Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler - // runs — but still in this route's envelope, because the hook puts it there. - expect(res.status, username).toBe(400) + // runs — but still the envelope at HTTP 200, like every other refusal here, + // because the hook puts it there. + expect(res.status, username).toBe(200) const body = (await res.json()) as { success: boolean; error: string; value: string } expect(body.success, username).toBe(false) expect(body.error).toMatch(/letters and numbers|at most 50 characters/)