The screen slice 1's API was written for: install from a release URL, enable, disable, uninstall, purge, and restart. Admin-only, matching the server, and core's own screen because it is how a module reaches the volume at all. 182 client tests (+21), manifest and OpenAPI unchanged. Everything that decides what a row SAYS and which buttons it offers is in `lib/moduleAdmin.js` -- plain JS, so the DOM-less runner can reach it, the same reason `lib/adminNav.js` is. The JSX renders what it returns. Three sources of truth, and they are allowed to disagree -------------------------------------------------------- The row records what the operator decided and what the last boot did; the loader says what is mounted and answering; the volume says whether there is a directory at all. Picking one and rendering it is simpler and lies. The case that makes it concrete is the one decision 3 creates on purpose: disable a module (its onShutdown runs) and enable it again, and the row says `enabled` while the loader still says `disabled` because nothing can start it before a restart. Neither "Running" nor "Disabled" is true; "Restart to start" is. Two shapes that are deliberately unlike the rest of the panel: the restart is a BANNER, because a restart is a property of the server rather than of a module and an operator who installed three modules should restart once; and purge is offered inside the uninstall flow as a second confirm, because purge.sql lives inside the directory being deleted and there is no later. What the browser found that no test could ----------------------------------------- Installing over a row the previous boot had left `startup_failed` rendered "Failed at the require stage: module directory not present on the volume" one second after the files had been written to the volume -- and, because that branch is not pending, it suppressed the restart banner the install had just told the operator to use. Every unit test passed, because none of them had modelled a stale row plus a fresh install. The fix is a derivation rather than a special case: the loader scans the volume once at require time, so a module that is on the volume now and has no live record arrived after that scan, and everything the row says about it predates the install. That check runs before the failure one. The same class, one place further on: an upgrade leaves the old code loaded, so the row's version is a promise about the next boot. `liveVersion` (slice 1) lets the screen say "Restart to finish upgrading" instead of reporting the new version as running. Verified against a live server and the real published release: pasted the v0.3.0 install-manifest URL, restarted, watched the module register its five mounts and seven streams and its own nav rows appear in the sidebar. Disable ran its onShutdown for real -- the uo-link WebSocket closed, its routes went to 404, and it left /public/modules -- and enable then showed the decision-3 state with the banner. The restart button itself was exercised through its endpoint rather than clicked, because a window.confirm wedges the browser automation. Co-Authored-By: Claude <noreply@anthropic.com>
188 lines
7.8 KiB
JavaScript
188 lines
7.8 KiB
JavaScript
import { test, beforeEach, afterEach } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { api, ApiError } from '../src/api/client.js'
|
|
|
|
// Unit-test the fetch wrapper that every API call flows through. The behaviors
|
|
// that matter to the whole app:
|
|
// - it always sends the session cookie (credentials: 'include');
|
|
// - a non-2xx response becomes a thrown ApiError carrying status + a message
|
|
// (server body.message → statusText → generic), never a silent bad value;
|
|
// - an empty body resolves to null (not a JSON parse throw);
|
|
// - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
|
|
// browser can set the multipart boundary);
|
|
// - query strings and path params are built/encoded correctly.
|
|
// We drive the real req() by mocking global.fetch and inspecting what it received.
|
|
|
|
let calls
|
|
const realFetch = global.fetch
|
|
|
|
// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
|
|
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText,
|
|
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
calls = []
|
|
global.fetch = async (url, opts) => {
|
|
calls.push({ url, opts })
|
|
return calls.nextReply || reply({ body: { ok: true } })
|
|
}
|
|
})
|
|
afterEach(() => {
|
|
global.fetch = realFetch
|
|
})
|
|
|
|
// helper to queue the next response
|
|
function willReply(r) {
|
|
global.fetch = async (url, opts) => {
|
|
calls.push({ url, opts })
|
|
return reply(r)
|
|
}
|
|
}
|
|
|
|
// ── happy path + cookie + base path ─────────────────────────────────────
|
|
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
|
|
willReply({ body: { user: { id: 1 } } })
|
|
const out = await api.me()
|
|
assert.equal(calls[0].url, '/api/v1/auth/me')
|
|
assert.equal(calls[0].opts.credentials, 'include')
|
|
assert.equal(calls[0].opts.method, 'GET')
|
|
assert.deepEqual(out, { user: { id: 1 } })
|
|
})
|
|
|
|
// ── error mapping ───────────────────────────────────────────────────────
|
|
test('a non-ok response throws an ApiError with status and the server message', async () => {
|
|
willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
|
|
await assert.rejects(
|
|
() => api.login('u', 'bad'),
|
|
(err) => {
|
|
assert.ok(err instanceof ApiError)
|
|
assert.equal(err.status, 401)
|
|
assert.equal(err.message, 'Incorrect username or password.')
|
|
assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
|
|
return true
|
|
},
|
|
)
|
|
})
|
|
|
|
test('an error with no JSON message falls back to statusText', async () => {
|
|
willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
|
|
await assert.rejects(
|
|
() => api.status(),
|
|
(err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
|
|
)
|
|
})
|
|
|
|
// ── empty body ──────────────────────────────────────────────────────────
|
|
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
|
|
willReply({ status: 200, body: '' })
|
|
const out = await api.logout()
|
|
assert.equal(out, null)
|
|
})
|
|
|
|
test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
|
|
willReply({ status: 200, body: 'plain text' })
|
|
const out = await api.me()
|
|
assert.equal(out, 'plain text')
|
|
})
|
|
|
|
// ── request body encoding ───────────────────────────────────────────────
|
|
test('a JSON POST serializes the body and sets Content-Type', async () => {
|
|
willReply({ body: { user: { id: 9 } } })
|
|
await api.register('newbie', 'pw', { company: '' })
|
|
const { opts } = calls[0]
|
|
assert.equal(opts.method, 'POST')
|
|
assert.equal(opts.headers['Content-Type'], 'application/json')
|
|
assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
|
|
})
|
|
|
|
test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
|
|
willReply({ body: { url: '/uploads/x.png' } })
|
|
const fakeFile = { name: 'x.png' }
|
|
await api.admin.upload(fakeFile)
|
|
const { opts } = calls[0]
|
|
assert.equal(opts.method, 'POST')
|
|
assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
|
|
assert.ok(opts.body instanceof FormData)
|
|
})
|
|
|
|
// ── query strings + path param encoding ─────────────────────────────────
|
|
test('wiki() builds a query string only from the params that are set', async () => {
|
|
willReply({ body: [] })
|
|
await api.wiki({ category: 'lore', q: 'dragon slayer' })
|
|
const url = new URL(calls[0].url, 'http://x')
|
|
assert.equal(url.pathname, '/api/v1/public/wiki')
|
|
assert.equal(url.searchParams.get('category'), 'lore')
|
|
assert.equal(url.searchParams.get('q'), 'dragon slayer')
|
|
assert.equal(url.searchParams.get('tag'), null) // omitted when unset
|
|
})
|
|
|
|
test('wiki() with no options sends no query string at all', async () => {
|
|
willReply({ body: [] })
|
|
await api.wiki()
|
|
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
|
})
|
|
|
|
test('path params are URL-encoded (a token with unsafe characters is escaped)', async () => {
|
|
willReply({ body: {} })
|
|
await api.getInvite('a b/c?d')
|
|
assert.equal(calls[0].url, '/api/v1/auth/invite/a%20b%2Fc%3Fd')
|
|
})
|
|
|
|
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
|
willReply({ body: {} })
|
|
await api.revokeMySession('a b/c')
|
|
assert.equal(calls[0].opts.method, 'DELETE')
|
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
|
})
|
|
|
|
// ── admin: installed modules (MODULE_SYSTEM.md §2.7.2) ──────────────────
|
|
//
|
|
// These pin the URLs, because the destructive one differs from the harmless one
|
|
// by a query parameter and nothing else.
|
|
|
|
test('module actions hit the right paths and methods', async () => {
|
|
const cases = [
|
|
[() => api.admin.listModules(), 'GET', '/api/v1/admin/modules'],
|
|
[() => api.admin.installModule('https://x/y.json'), 'POST', '/api/v1/admin/modules'],
|
|
[() => api.admin.enableModule('uo'), 'POST', '/api/v1/admin/modules/uo/enable'],
|
|
[() => api.admin.disableModule('uo'), 'POST', '/api/v1/admin/modules/uo/disable'],
|
|
[() => api.admin.purgeModule('uo'), 'POST', '/api/v1/admin/modules/uo/purge'],
|
|
[() => api.admin.setModuleSources('a.com'), 'PUT', '/api/v1/admin/modules/sources'],
|
|
[() => api.admin.restartServer(), 'POST', '/api/v1/admin/modules/restart'],
|
|
]
|
|
for (const [call, method, url] of cases) {
|
|
calls = []
|
|
willReply({ body: {} })
|
|
await call()
|
|
assert.equal(calls[0].url, url)
|
|
assert.equal(calls[0].opts.method || 'GET', method)
|
|
}
|
|
})
|
|
|
|
test('uninstall only asks for a purge when it is told to', async () => {
|
|
// The difference between "remove the module" and "remove the module and drop
|
|
// every table it owns" is this query parameter, so a default that leaned the
|
|
// wrong way would be irreversible.
|
|
willReply({ body: {} })
|
|
await api.admin.uninstallModule('uo')
|
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo')
|
|
assert.equal(calls[0].opts.method, 'DELETE')
|
|
|
|
calls = []
|
|
willReply({ body: {} })
|
|
await api.admin.uninstallModule('uo', { purge: true })
|
|
assert.equal(calls[0].url, '/api/v1/admin/modules/uo?purge=true')
|
|
})
|
|
|
|
test('a module id is URL-encoded on the way into the path', async () => {
|
|
willReply({ body: {} })
|
|
await api.admin.disableModule('a b/c')
|
|
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
|
})
|