feat(modules): publish the installed-module list at /api/v1/public/modules
Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md 2.7 — the first module-system
URL a client can see. The SPA and the Android app feature-detect against the
capabilities a module declares; the shape is settled in MODULE_API.md 2.9.
Four decisions, and what is absent from the payload is most of the design:
* started modules only. A module that is disabled or failed to load is
ABSENT, exactly as 4.4 already leaves its routes and its nav absent, so a
client renders a site without that capability rather than advertising one
that 503s.
* no state, failure_stage or failure_reason. Where a module broke belongs to
the admin Modules screen, and the reason is an exception string from inside
core — not anonymous-visitor business.
* no client chunk URL. htmlShell injects a script tag per started module
(3.1.3), so the browser is handed the tag rather than a URL to fetch. This
endpoint feature-detects; it does not load. MODULE_SYSTEM 2.6 step 4 is
amended to match (API 6.7).
* no siteMode gate and no database — the same class as /public/status and
/public/version, so a client can still feature-detect during maintenance.
It is a capability router of its own rather than a fifth singleton in
site.router.js, and that is load-bearing: the loader's prefix-collision probe
reads the live tier stack and skips root-mounted layers, because a use('/', ...)
matches every path. A route inside the root-mounted site router would be
invisible to it — mounting use('/modules', ...) is what makes "no module may
claim /modules" a rule the loader enforces.
910 tests pass (+9, every one on the boundary — what must NOT appear).
routes.manifest.json gains exactly the one route and routes.guards.json records
it with an empty gates list, which is itself the assertion that it is ungated.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1947,6 +1947,12 @@
|
||||
"validate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules",
|
||||
"handlers": 1,
|
||||
"gates": []
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token",
|
||||
|
||||
@@ -781,6 +781,10 @@
|
||||
"method": "POST",
|
||||
"path": "/api/v1/public/contact"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/modules"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/public/pages/:id/preview/:token"
|
||||
|
||||
@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
|
||||
const pagesRouter = require('./pages.router')
|
||||
const shardRouter = require('./shard.router')
|
||||
const atlasRouter = require('./atlas.router')
|
||||
const modulesRouter = require('./modules.router')
|
||||
const siteRouter = require('./site.router')
|
||||
|
||||
const publicRouter = express.Router()
|
||||
@@ -38,6 +39,12 @@ publicRouter.use('/shard', shardRouter)
|
||||
// here depends on the bridge — and site-mode gated per route like the content
|
||||
// routers above, which is the other half of that distinction.
|
||||
publicRouter.use('/atlas', atlasRouter)
|
||||
// What this backend serves beyond core. A real prefix layer rather than a fifth
|
||||
// singleton in site.router.js, because the loader's prefix-collision probe reads
|
||||
// the live tier stack and skips root-mounted layers — this mount is what makes
|
||||
// /modules unclaimable by a module. Never site-mode gated: a client must be able
|
||||
// to feature-detect while the site is in maintenance.
|
||||
publicRouter.use('/modules', modulesRouter)
|
||||
|
||||
// The four singletons that own no path segment of their own: /settings, /status,
|
||||
// /version and /contact. Mounted at the group root, last — safe only because
|
||||
|
||||
60
server/src/router/v1/public/modules.controller.js
Normal file
60
server/src/router/v1/public/modules.controller.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Public · Modules — what this backend is currently serving beyond core.
|
||||
//
|
||||
// Phase 2, PR 6 of docs/website/MODULE_SYSTEM.md §2.7. The published shape is
|
||||
// settled in MODULE_API.md §2.1 (`capabilities` are opaque strings, published
|
||||
// here, for clients to feature-detect against).
|
||||
//
|
||||
// Two decisions are visible in the ten lines below and are the whole of this
|
||||
// file's design:
|
||||
//
|
||||
// • **`started` only.** The public surface answers "what is serving", and
|
||||
// nothing else. A module that failed to load, or that an operator disabled,
|
||||
// is simply ABSENT — the same treatment §4.4 already gives its routes and
|
||||
// its nav, so an anonymous visitor sees a site without that capability
|
||||
// rather than a site advertising a capability that 503s. `state`, the
|
||||
// failure stage and the failure reason are core's business and belong to the
|
||||
// admin Modules screen; none of the three is published here.
|
||||
// • **No database, and no siteMode gate.** The answer comes from the loader's
|
||||
// in-memory records, so this endpoint keeps working with the database down —
|
||||
// the same class as /public/version and /public/status, both of which must
|
||||
// answer during maintenance so a client can bootstrap and render the
|
||||
// maintenance page. A client that could not feature-detect while the site
|
||||
// was in maintenance would render its maintenance page as though no module
|
||||
// existed.
|
||||
//
|
||||
// This endpoint is deliberately NOT how a module's client chunk gets loaded.
|
||||
// `utils/htmlShell.js` injects a `<script type="module">` per started module
|
||||
// (MODULE_API.md §3.1.3), so the browser is handed the tag rather than a URL to
|
||||
// go and fetch; there is no `client` field here for the same reason there is no
|
||||
// second copy of any other fact. See MODULE_SYSTEM.md §2.6, amended to match.
|
||||
|
||||
const loader = require('../../../modules/loader')
|
||||
|
||||
const log = require('../../../utils/logger')('public:modules')
|
||||
|
||||
/** id, name, version and capabilities — everything else the loader knows is internal. */
|
||||
const publish = (m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
version: m.version,
|
||||
capabilities: m.capabilities,
|
||||
})
|
||||
|
||||
function getModules(req, res) {
|
||||
try {
|
||||
// Scan order (alphabetical by id) comes from the loader and is preserved:
|
||||
// there is no dependency resolution, so any other order would imply a
|
||||
// precedence nothing computes (MODULE_API.md §4.2).
|
||||
return res.json({ modules: loader.list().filter((m) => m.state === 'started').map(publish) })
|
||||
} catch (err) {
|
||||
// The only reachable throw is §7.6's guard — the module list read before
|
||||
// modules.load() ran. That is a mis-ordered boot, not a bad request, so it
|
||||
// is logged rather than answered with an empty list: `{ modules: [] }` is a
|
||||
// true answer for a core with no modules installed and a caller cannot tell
|
||||
// the two apart.
|
||||
log.error('module list unavailable', { message: err.message })
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getModules }
|
||||
30
server/src/router/v1/public/modules.router.js
Normal file
30
server/src/router/v1/public/modules.router.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// Public · Modules — the installed-module list a client feature-detects against.
|
||||
//
|
||||
// Mounted at /api/v1/public/modules by public/index.js. One route, and it owns a
|
||||
// prefix rather than sitting beside /settings and /version in site.router.js —
|
||||
// which is the point of the file existing at all. The loader asks the LIVE
|
||||
// public tier router whether a prefix is already core's (`ownedByCore`,
|
||||
// modules/loader.js), and it skips root-mounted layers because a `use('/', …)`
|
||||
// matches every path. A route declared inside the root-mounted site router is
|
||||
// therefore invisible to that probe; a real `use('/modules', …)` layer is not.
|
||||
// So mounting it here is what makes "no module may ever claim /modules" an
|
||||
// enforced rule instead of a convention.
|
||||
//
|
||||
// No siteMode gate and no database — see modules.controller.js for why.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const ctrl = require('./modules.controller')
|
||||
|
||||
const modulesRouter = express.Router()
|
||||
|
||||
modulesRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Public']
|
||||
// #swagger.summary = 'Installed modules (id, version, capabilities)'
|
||||
// #swagger.description = 'The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.'
|
||||
/* #swagger.responses[200] = { description: 'The started modules', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicModules" } } } } */
|
||||
ctrl.getModules,
|
||||
)
|
||||
|
||||
module.exports = modulesRouter
|
||||
@@ -11620,6 +11620,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/modules": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Public"
|
||||
],
|
||||
"summary": "Installed modules (id, version, capabilities)",
|
||||
"description": "The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The started modules",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PublicModules"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/public/pages/{id}/preview/{token}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -17787,6 +17811,131 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicModules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"modules": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PublicModule"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicModule": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One started module, as published to anonymous clients."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "uo"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Module id — also the URL segment its routes live under (/api/v1/public/<id-owned prefixes>)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Ultima Online"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Human label."
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "1.0.0"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The module's own version (semver). Unrelated to the API version."
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent."
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "shard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Brand": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -737,6 +737,31 @@ const doc = {
|
||||
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
|
||||
},
|
||||
},
|
||||
PublicModules: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated.',
|
||||
properties: {
|
||||
modules: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/PublicModule' },
|
||||
},
|
||||
},
|
||||
},
|
||||
PublicModule: {
|
||||
type: 'object',
|
||||
description: 'One started module, as published to anonymous clients.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'uo', description: 'Module id — also the URL segment its routes live under (/api/v1/public/<id-owned prefixes>).' },
|
||||
name: { type: 'string', example: 'Ultima Online', description: 'Human label.' },
|
||||
version: { type: 'string', example: '1.0.0', description: 'The module\'s own version (semver). Unrelated to the API version.' },
|
||||
capabilities: {
|
||||
type: 'array',
|
||||
description: 'Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent.',
|
||||
items: { type: 'string', example: 'shard' },
|
||||
},
|
||||
},
|
||||
},
|
||||
Brand: {
|
||||
type: 'object',
|
||||
description:
|
||||
|
||||
202
server/test/publicModules.test.js
Normal file
202
server/test/publicModules.test.js
Normal file
@@ -0,0 +1,202 @@
|
||||
// ── GET /api/v1/public/modules ─────────────────────────────────────────────
|
||||
//
|
||||
// Phase 2, PR 6. The endpoint is small; what is worth testing is the boundary it
|
||||
// draws. `installed_modules` records five states and the loader records a
|
||||
// failure stage and reason beside them, and exactly none of that may reach an
|
||||
// anonymous caller — the public answer is "what is serving", and a module that
|
||||
// is not serving is ABSENT (MODULE_API.md §4.4, MODULE_SYSTEM.md §2.4).
|
||||
//
|
||||
// A leak here would not fail anything else: the routes still 503, the nav is
|
||||
// still absent, the site still boots. It would just quietly publish that a
|
||||
// module broke and how far it got. So the tests below assert the negative — no
|
||||
// `state`, no `stage`, no `reason`, no extra key — as well as the positive.
|
||||
//
|
||||
// Like the other module tests, each case builds a throwaway modules directory,
|
||||
// points MODULES_DIR at it and re-requires the loader AND the controller with a
|
||||
// clean cache: the controller holds the loader in a file-scope const, so a stale
|
||||
// cache would leave it reading the previous test's module list.
|
||||
//
|
||||
// Point the pool at a closed port before requiring anything — buildCtx pulls in
|
||||
// the models, which build a mariadb pool at require time. That no test here has
|
||||
// to stub a query is itself the point: this endpoint never touches a database.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const { test, beforeEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const express = require('express')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
const registries = require('../src/modules/registries')
|
||||
const { startApp } = require('./_helper')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let tmpRoot
|
||||
|
||||
const emptyTiers = () => ({
|
||||
public: express.Router(),
|
||||
admin: express.Router(),
|
||||
player: express.Router(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Load a throwaway modules directory and hand back the loader plus a live app
|
||||
* serving the real router at the real path.
|
||||
*/
|
||||
function freshApp(dir) {
|
||||
process.env.MODULES_DIR = dir
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
delete require.cache[require.resolve('../src/router/v1/public/modules.controller')]
|
||||
delete require.cache[require.resolve('../src/router/v1/public/modules.router')]
|
||||
/* eslint-disable global-require */
|
||||
const loader = require('../src/modules/loader')
|
||||
loader.load(emptyTiers())
|
||||
const modulesRouter = require('../src/router/v1/public/modules.router')
|
||||
/* eslint-enable global-require */
|
||||
return { loader, mount: (app) => app.use('/api/v1/public/modules', modulesRouter) }
|
||||
}
|
||||
|
||||
function writeModule(id, manifest = {}) {
|
||||
const dir = path.join(tmpRoot, id)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
||||
id, name: id, version: '1.0.0', coreApi: '^1.0.0', ...manifest,
|
||||
}))
|
||||
return dir
|
||||
}
|
||||
|
||||
async function get(mount) {
|
||||
const app = await startApp(mount)
|
||||
try {
|
||||
const res = await fetch(`${app.url}/api/v1/public/modules`)
|
||||
return { status: res.status, body: await res.json() }
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-pubmod-'))
|
||||
})
|
||||
|
||||
// ── The answer ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('a core with no modules answers an empty list, not a 404', async () => {
|
||||
// The shipping state of Phase 2: the endpoint exists and is truthful before
|
||||
// any module does. A client that has to distinguish "no modules" from "old
|
||||
// backend" would otherwise have to read a status code to find out.
|
||||
const { mount } = freshApp(path.join(tmpRoot, 'does-not-exist'))
|
||||
const res = await get(mount)
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body, { modules: [] })
|
||||
})
|
||||
|
||||
test('a started module is published with exactly id, name, version and capabilities', async () => {
|
||||
writeModule('uo', { name: 'Ultima Online', version: '2.1.0', capabilities: ['shard', 'atlas'] })
|
||||
const { loader, mount } = freshApp(tmpRoot)
|
||||
loader.setState('uo', 'started')
|
||||
|
||||
const res = await get(mount)
|
||||
assert.deepEqual(res.body, {
|
||||
modules: [{ id: 'uo', name: 'Ultima Online', version: '2.1.0', capabilities: ['shard', 'atlas'] }],
|
||||
})
|
||||
// Asserted by key set as well as by value: a field added to loader.list()
|
||||
// later must not reach the public surface just because it was added.
|
||||
assert.deepEqual(Object.keys(res.body.modules[0]).sort(), ['capabilities', 'id', 'name', 'version'])
|
||||
})
|
||||
|
||||
test('a module that declares no capabilities publishes an empty array, never undefined', async () => {
|
||||
// `capabilities` is optional in module.json (§2.1). A client iterating the
|
||||
// array must not have to null-check it.
|
||||
writeModule('bare')
|
||||
const { loader, mount } = freshApp(tmpRoot)
|
||||
loader.setState('bare', 'started')
|
||||
|
||||
const res = await get(mount)
|
||||
assert.deepEqual(res.body.modules[0].capabilities, [])
|
||||
})
|
||||
|
||||
test('modules are published in scan order', async () => {
|
||||
for (const id of ['zeta', 'alpha', 'mid']) writeModule(id)
|
||||
const { loader, mount } = freshApp(tmpRoot)
|
||||
for (const id of ['zeta', 'alpha', 'mid']) loader.setState(id, 'started')
|
||||
|
||||
const res = await get(mount)
|
||||
// Alphabetical, because that is the loader's scan order and there is no
|
||||
// dependency resolution — any other order would imply a precedence nothing
|
||||
// computes (§4.2).
|
||||
assert.deepEqual(res.body.modules.map((m) => m.id), ['alpha', 'mid', 'zeta'])
|
||||
})
|
||||
|
||||
// ── The boundary ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a registered-but-not-yet-started module is absent', async () => {
|
||||
// The state between a clean load and onBoot. It is not serving yet, so it is
|
||||
// not published — the endpoint answers what IS serving, not what will be.
|
||||
writeModule('uo')
|
||||
const { mount } = freshApp(tmpRoot)
|
||||
const res = await get(mount)
|
||||
assert.deepEqual(res.body.modules, [])
|
||||
})
|
||||
|
||||
test('a disabled module is absent — the operator switched it off', async () => {
|
||||
writeModule('uo')
|
||||
const { loader, mount } = freshApp(tmpRoot)
|
||||
loader.setState('uo', 'disabled')
|
||||
|
||||
const res = await get(mount)
|
||||
assert.deepEqual(res.body.modules, [])
|
||||
})
|
||||
|
||||
test('a failed module is absent, and its stage and reason never leave the server', async () => {
|
||||
// The leak this whole file exists to prevent. `startup_failed` carries the
|
||||
// step that broke and the error message; both belong to the admin Modules
|
||||
// screen and neither is anonymous-visitor business.
|
||||
writeModule('uo', { capabilities: ['shard'] })
|
||||
const { loader, mount } = freshApp(tmpRoot)
|
||||
loader.setState('uo', 'startup_failed', { stage: 'require', reason: 'Cannot find module ./nope' })
|
||||
|
||||
const res = await get(mount)
|
||||
assert.deepEqual(res.body.modules, [])
|
||||
const raw = JSON.stringify(res.body)
|
||||
for (const leak of ['require', 'nope', 'startup_failed', 'stage', 'reason']) {
|
||||
assert.ok(!raw.includes(leak), `published "${leak}"`)
|
||||
}
|
||||
})
|
||||
|
||||
test('one failed module does not hide the ones that started', async () => {
|
||||
writeModule('broken')
|
||||
writeModule('working', { capabilities: ['shard'] })
|
||||
const { loader, mount } = freshApp(tmpRoot)
|
||||
loader.setState('broken', 'startup_failed', { stage: 'schema', reason: 'boom' })
|
||||
loader.setState('working', 'started')
|
||||
|
||||
const res = await get(mount)
|
||||
assert.deepEqual(res.body.modules.map((m) => m.id), ['working'])
|
||||
})
|
||||
|
||||
// ── Failure ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('the module list read before load() is a 500, not a lie', async () => {
|
||||
// §7.6: `{ modules: [] }` is a true answer for a core with no modules and a
|
||||
// caller cannot tell it from a mis-ordered boot. So the guard's throw becomes
|
||||
// a 500 rather than an empty list — the one case where an error is the honest
|
||||
// answer.
|
||||
process.env.MODULES_DIR = tmpRoot
|
||||
registries._reset()
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
delete require.cache[require.resolve('../src/router/v1/public/modules.controller')]
|
||||
delete require.cache[require.resolve('../src/router/v1/public/modules.router')]
|
||||
// eslint-disable-next-line global-require
|
||||
const modulesRouter = require('../src/router/v1/public/modules.router')
|
||||
|
||||
const res = await get((app) => app.use('/api/v1/public/modules', modulesRouter))
|
||||
assert.equal(res.status, 500)
|
||||
})
|
||||
Reference in New Issue
Block a user