repair account/me/username endpoint

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