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
135 lines
5.4 KiB
JavaScript
135 lines
5.4 KiB
JavaScript
// ── 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\``)
|
|
}
|
|
})
|