fix(rust): answer refusals in the field core reads, and show the name the game last saw
The two defects the phase 6 browser walk found and #6 described but did not carry. They were written, walked and left uncommitted; `edge` still has the shapes the walk condemned. **Every refusal sentence was invisible.** Core's request primitive reads one field — `(data && data.message) || res.statusText` — and this module has answered `{ error: … }` since phase 1. It got away with it because every failure until phase 6 landed in `ErrorState` on a page whose whole content was missing, where a generic sentence is honest. A form is different: the sentence IS the outcome, and the link page showed *Service Unavailable* for all four of the refusals phase 6 exists to write. All 23 bodies now answer in `message` — core's `Error` schema, which these routes' own `#swagger.responses` already referenced, so the annotations stop being a claim the handlers contradict. `test/errorShape.test.js` drives each outcome rather than grepping for the field, and asserts the half that is easy to leave behind: a body carrying BOTH fields renders correctly in a browser and keeps the wrong shape alive for the next route that copies it. **The player saw a stale name.** `/player/rust` showed the name recorded at link time while the admin panel showed the one the game last saw — the same person labelled two ways on one site, because a Rust name changes on a whim and only the admin read joined `rust_players`. A LEFT JOIN, because an account can be linked and never played on. 123 server tests, 39 client tests, `check:imports`, `check:bundle`, `check:swagger`, `check:externals` — all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
134
server/test/errorShape.test.js
Normal file
134
server/test/errorShape.test.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── The field an error has to be in ───────────────────────────────────────
|
||||
//
|
||||
// **The walk found this, and no test could have.** Core's request primitive is
|
||||
// the only thing that reads a module's failures:
|
||||
//
|
||||
// const message = (data && data.message) || res.statusText || 'Request failed'
|
||||
//
|
||||
// So a body shaped `{ error: '…' }` is not rendered as a worse message — it is
|
||||
// not rendered at all. The player sees `Service Unavailable`, which is what the
|
||||
// link page showed for every one of the four sentences this phase exists to
|
||||
// write, until a browser said so.
|
||||
//
|
||||
// This module answered `{ error }` from its first phase and got away with it,
|
||||
// because until now every failure landed in `ErrorState` on a page whose whole
|
||||
// content was missing — where a generic sentence is honest. A form is different:
|
||||
// the sentence IS the outcome, and the four are not interchangeable.
|
||||
//
|
||||
// The rule is core's `Error` schema (`{ message }`), which every one of this
|
||||
// module's `#swagger.responses` already pointed at. So this suite is the schema
|
||||
// those annotations claim, asserted against what the handlers actually send.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
function withCore() {
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
}
|
||||
|
||||
/** A response double that records the status and the body. */
|
||||
function fakeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(code) {
|
||||
res.statusCode = code
|
||||
return res
|
||||
},
|
||||
json(body) {
|
||||
res.body = body
|
||||
return res
|
||||
},
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/** Every outcome `redeem` can answer, and the status each has to become. */
|
||||
const OUTCOMES = [
|
||||
[{ ok: false, reason: 'taken', username: 'someone-else' }, 409, /already linked to someone-else/],
|
||||
[{ ok: false, reason: 'unsure' }, 503, /still good/],
|
||||
[{ ok: false, reason: 'offline' }, 503, /unreachable/],
|
||||
[{ ok: false, reason: 'no-servers' }, 503, /No Rust servers/],
|
||||
[{ ok: false, reason: 'rejected' }, 400, /unknown or has expired/],
|
||||
]
|
||||
|
||||
test('every refusal reaches the player as a sentence, in the field core reads', async () => {
|
||||
for (const [outcome, status, matches] of OUTCOMES) {
|
||||
withCore()
|
||||
const links = require('../model/links/links.model')
|
||||
const controller = require('../router/player/rust.controller')
|
||||
|
||||
links.redeem = async () => outcome
|
||||
|
||||
const res = fakeRes()
|
||||
await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res)
|
||||
|
||||
assert.equal(res.statusCode, status, `${outcome.reason} must be ${status}`)
|
||||
assert.equal(typeof res.body.message, 'string', `${outcome.reason} sent no \`message\``)
|
||||
assert.match(res.body.message, matches)
|
||||
|
||||
// The half that is easy to leave behind while fixing this: a body carrying
|
||||
// BOTH fields reads correctly in a browser and keeps the wrong shape alive
|
||||
// for the next route that copies it.
|
||||
assert.equal(res.body.error, undefined, `${outcome.reason} still carries an \`error\` field`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the five outcomes are five different statuses-and-sentences, not one', async () => {
|
||||
const seen = new Set()
|
||||
|
||||
for (const [outcome] of OUTCOMES) {
|
||||
withCore()
|
||||
const links = require('../model/links/links.model')
|
||||
const controller = require('../router/player/rust.controller')
|
||||
|
||||
links.redeem = async () => outcome
|
||||
|
||||
const res = fakeRes()
|
||||
await controller.confirmLink({ body: { code: 'K7M2PQ' }, user: { id: 4 } }, res)
|
||||
seen.add(res.body.message)
|
||||
}
|
||||
|
||||
// "That code is wrong" and "we could not reach the server that has it" send a
|
||||
// player to do different things, and one of the two is a dead end when it is
|
||||
// wrong — they run /link again on the server that is down and get the same
|
||||
// answer for as long as it stays down.
|
||||
assert.equal(seen.size, OUTCOMES.length, 'two outcomes tell the player the same thing')
|
||||
})
|
||||
|
||||
test('no handler in this module answers in a field core cannot read', async () => {
|
||||
// The other controllers, the same way — driven rather than grepped, because the
|
||||
// shape that matters is what a handler SENDS. Each is given a model that throws,
|
||||
// which is every controller's own 500 path and the one branch they all have.
|
||||
withCore()
|
||||
|
||||
const cases = [
|
||||
['public', '../router/public/rust.controller', 'listServers', { params: {}, query: {} }],
|
||||
['player', '../router/player/rust.controller', 'listServers', { params: {}, query: {}, user: { id: 4 } }],
|
||||
['player', '../router/player/rust.controller', 'listLinks', { params: {}, user: { id: 4 } }],
|
||||
['admin', '../router/admin/rust.controller', 'listServers', { params: {}, query: {} }],
|
||||
['slot', '../router/admin/usersRust.controller', 'listLinks', { params: { id: '4' } }],
|
||||
]
|
||||
|
||||
for (const [tier, modulePath, handler, req] of cases) {
|
||||
withCore()
|
||||
|
||||
// Core's `query` is the fake's spy; make it throw so every handler takes its
|
||||
// failure branch.
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx({
|
||||
db: { query: () => Promise.reject(new Error('the database is not there')), pool: {} },
|
||||
}))
|
||||
|
||||
const controller = require(modulePath)
|
||||
const res = fakeRes()
|
||||
await controller[handler](req, res)
|
||||
|
||||
assert.equal(res.statusCode, 500, `${tier}.${handler} did not fail`)
|
||||
assert.equal(typeof res.body.message, 'string', `${tier}.${handler} sent no \`message\``)
|
||||
assert.equal(res.body.error, undefined, `${tier}.${handler} answers in \`error\``)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user