// 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()) }, // MODULE_API 1.7.0. Both are fire-and-forget and return undefined by // contract — a module gets no delivery answer back, deliberately — so the // spies return undefined rather than a promise, which is what core does. events: { emit: spy(undefined), reconcile: spy(undefined) }, inbox: { push: spy(undefined) }, 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: [], teamProvider: null, slashCommands: [], triggers: null, audiences: null, eventActions: null, eventBudgets: null, eventOptionSources: null, 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) }, // MODULE_API 1.6.0. `once` because core holds a single provider per // deployment — a second registration is a collision there, so it has to be // one here too, or this suite would pass a shape core rejects at load. registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider }, // MODULE_API 1.6.0, live since phase 7. `once` for the same reason core // takes it: a second call is a module changing its mind halfway through // register(), which core rejects. registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands }, // MODULE_API 1.7.0, live since ENGAGEMENT.md Phase 11. `once` on both, for // the reason above: core stages a registrant's whole batch and applies it as // one, so a second call is a module changing its mind mid-register(). registerEventTriggers(triggers) { once('registerEventTriggers'); record.triggers = triggers }, registerAudiences(audiences) { once('registerAudiences'); record.audiences = audiences }, // MODULE_API 1.9.0 (ENGAGEMENT.md Phase 11b). `once` again, and here it is // load-bearing rather than tidy: a rule belongs to exactly ONE named group, // and merging two calls would make "which group is this rule in" — the // question the one-shot seed guard answers — unanswerable. registerEngagementSeeds(seeds) { once('registerEngagementSeeds'); record.engagementSeeds = seeds }, // MODULE_API 1.10.0 (EVENTS.md F, EVENTS_PLAN.md Phases 7 and 9). `once` on // all three, matching core: it stages a registrant's whole batch and applies // it as one, so a second call is a module changing its mind mid-register(). registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions }, registerEventBudgets(budgets) { once('registerEventBudgets'); record.eventBudgets = budgets }, registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources }, // And the fourth, from Phase 11b. `once` for the same reason, and present here // for a second one: a verb this module calls and this fake does not have is a // TypeError in `entry.test.js` rather than a surprise at somebody's boot. registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases }, 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 }