Files
Module-Rust/server/test/errorShape.test.js
wtclaude 0876a1d568
All checks were successful
PR Checks / server-tests (pull_request) Successful in 15s
PR Checks / frozen-manifest (pull_request) Successful in 48s
PR Checks / client-build (pull_request) Successful in 7m49s
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
2026-09-21 17:34:21 -05:00

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\``)
}
})