Files
Module-uo/server/test/_fakes.js
wtclaude 6b99d7e220
All checks were successful
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / server-tests (pull_request) Successful in 8m47s
test(server): port core's UO suite onto the ctx harness
22 test files moved from core, plus the two that were split out of files core
keeps. 351 tests pass.

One change runs through every moved test, and it is the boundary rather than a
chore: core internals can no longer be stubbed by requiring them, because there
are none to require. `../utils/db` and `../model/settings` do not exist here.
What a test controls instead is the ctx core would have handed over, installed
once by test/_setup.js -- which is a better seam anyway, since it is exactly the
surface the contract promises and nothing wider.

The ctx _setup installs is deliberately unfrozen. Core freezes what it hands a
module and entry.test.js still asserts against a frozen one; but a test that
needs settings.get to return a path has to be able to say so.

Two tests changed SHAPE, and that is the boundary too. fromShardEvent used to
assert through publish() into pushDevices and a captured fetch -- which
endpoints were hit, how many requests went out. None of that is this module's
any more: publish is ctx.push.publish, and the device registry and the relay are
behind it. Reaching for them from here would be reaching past ctx. What remains
is what the module owns and is the part worth guarding: a game account resolves
to a website user, a personal target that resolves to nobody is dropped rather
than published, and a sensitive kind never reaches publish at all.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 12:07:15 -05:00

118 lines
4.7 KiB
JavaScript

// Test doubles for what core hands the module.
//
// The module's server half is testable WITHOUT core, and that is not a
// convenience — it is the contract holding. Everything the module may touch
// arrives on `ctx` (MODULE_API.md §2.3), so a `ctx` this file can build is a
// complete statement of the module's dependencies. If a test ever needs
// something that is not here, either the module reached past the boundary or
// §2.3 needs a member; both are worth stopping for.
//
// `fakeCtx` mirrors §2.3 member for member, including the freezing, so a module
// that assigns to `ctx.something` fails here the way it would in core.
const express = require('express')
/** Records every call, so a test can assert what a module asked for. */
function spy(returns) {
const fn = (...args) => {
fn.calls.push(args)
return typeof returns === 'function' ? returns(...args) : returns
}
fn.calls = []
return fn
}
function fakeLog() {
const log = { error: spy(), warn: spy(), info: spy(), debug: spy() }
return log
}
function fakeCtx(overrides = {}) {
// `freeze: false` is for _setup.js, which installs one process-wide ctx a test
// may adjust. Core always freezes; the unfrozen variant is a test seam and
// never a claim about what a module is handed in production.
const { freeze = true, ...rest } = overrides
const logs = []
const ctx = {
moduleId: 'uo',
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
express,
validator: require('express-validator'),
db: { query: spy(Promise.resolve([])), pool: {} },
log: (namespace) => {
const log = fakeLog()
logs.push({ namespace, log })
return log
},
settings: { get: spy(Promise.resolve(null)), set: spy(Promise.resolve()), getInstanceName: spy(Promise.resolve('Test')) },
auth: { getUserFromRequest: spy(null) },
push: { publish: spy(Promise.resolve()) },
secretBox: { encrypt: spy('enc'), decrypt: spy('dec') },
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
siteMode: (req, res, next) => next(),
validate: (req, res, next) => next(),
noindex: (req, res, next) => next(),
// API 1.1.0. The factory returns a pass-through rather than a real
// limiter: a test that tripped a rate limit would be a test whose result
// depended on how many times the suite had run.
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
accountChangeLimiter: (req, res, next) => next(),
},
uploads: { upload: {}, UPLOAD_DIR: '/tmp', MIME_EXT: {} },
posts: { listAll: spy(Promise.resolve([])), getById: spy(Promise.resolve(null)), linkAnnounceJob: spy(Promise.resolve()), markAnnounced: spy(Promise.resolve()) },
// The three §2.3 members API 1.1.0 added for this extraction.
activity: { log: spy(Promise.resolve()) },
users: { getById: spy(Promise.resolve(null)) },
site: { baseUrl: 'http://localhost:5173' },
...rest,
}
// Non-enumerable, and that is not tidiness. Core freezes every object value on
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
// by the loop below and every `log.info` call would throw on push — which is
// how this was found. Keeping it off the enumeration also makes the fake more
// faithful: a module iterating `ctx` sees exactly §2.3's members and nothing
// a test put there.
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
if (!freeze) return ctx
for (const value of Object.values(ctx)) {
if (value && typeof value === 'object') Object.freeze(value)
}
return Object.freeze(ctx)
}
/**
* The registration api, recording rather than mounting.
*
* Copies core's `once()` rule (§2.4: "calling twice is an error") because a
* module that registers the same thing twice must fail in its own test suite
* and not first on an operator's install.
*/
function fakeApi() {
const record = {
routes: null,
extensions: [],
streams: null,
legs: [],
hooks: {},
}
const called = new Set()
const once = (name) => {
if (called.has(name)) throw new Error(`${name}() called twice`)
called.add(name)
}
const api = {
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}
api.record = record
return api
}
module.exports = { fakeCtx, fakeApi, spy }