import assert from 'node:assert/strict'; import test from 'node:test'; import { attachPortalAgentJobRoutes } from './portal-agent-job-routes.mjs'; function createRouterRecorder() { const routes = new Map(); return { routes, get(path, handler) { routes.set(`GET ${path}`, handler); }, post(path, handler) { routes.set(`POST ${path}`, handler); }, }; } function createResponseRecorder() { return { statusCode: 200, body: undefined, headers: {}, writes: [], ended: false, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, writeHead(code, headers) { this.statusCode = code; Object.assign(this.headers, headers); return this; }, write(chunk) { this.writes.push(chunk); return true; }, end() { this.ended = true; return this; }, set(name, value) { this.headers[name] = value; return this; }, setHeader(name, value) { this.headers[name] = value; }, send(body) { this.body = body; return this; }, }; } function createRequest(overrides = {}) { const listeners = {}; return { body: {}, query: {}, params: {}, currentUser: { id: 'user-1' }, ip: '127.0.0.1', requestId: 'request-1', listeners, on(event, handler) { listeners[event] = handler; }, get() { return undefined; }, ...overrides, }; } function createDependencies(overrides = {}) { const calls = { data: [], errors: [], }; return { calls, dependencies: { ensureMindSpaceEnabled: () => true, sendData(res, req, data, status = 200) { calls.data.push({ res, req, data, status }); return res.status(status).json({ data }); }, handleMindSpaceError(res, req, error) { calls.errors.push({ res, req, error }); return res.status(418).json({ mapped: error.message }); }, ...overrides, }, }; } test('Agent Job module preserves route inventory and order', () => { const api = createRouterRecorder(); attachPortalAgentJobRoutes(api); assert.deepEqual([...api.routes.keys()], [ 'POST /mindspace/v1/agent/jobs', 'GET /mindspace/v1/agent/jobs/:jobId', 'GET /mindspace/v1/agent/jobs/:jobId/stream', 'GET /mindspace/v1/agent/jobs', 'POST /mindspace/v1/agent/jobs/:jobId/cancel', 'POST /mindspace/v1/agent/jobs/:jobId/retry', 'POST /mindspace/v1/agent/jobs/:jobId/run', 'POST /internal/agent/jobs/:jobId/claim', 'GET /internal/agent/jobs/:jobId/assets/:assetId', 'POST /internal/agent/jobs/:jobId/heartbeat', 'POST /internal/agent/jobs/:jobId/complete', ]); }); test('create job preserves request mapping, audit, status, and error mapping', async () => { const calls = []; const job = { id: 'job-1', jobType: 'summarize', assets: [{ assetId: 'asset-1' }], }; const api = createRouterRecorder(); const success = createDependencies({ getMindSpaceAgentJobs: () => ({ async createJob(userId, input) { calls.push({ kind: 'create', userId, input }); return job; }, }), getMindSpaceAudit: () => ({ async write(entry) { calls.push({ kind: 'audit', entry }); }, }), }); attachPortalAgentJobRoutes(api, success.dependencies); const req = createRequest({ ip: '10.0.0.3', body: { job_type: 'summarize', instruction: 'Summarize', allowed_asset_ids: ['asset-1'], output_category_id: 'category-1', output_type: 'page', idempotency_key: 'key-1', locale: 'zh-CN', timezone: 'Asia/Shanghai', capabilities: ['read'], }, }); const res = createResponseRecorder(); await api.routes.get('POST /mindspace/v1/agent/jobs')(req, res); assert.deepEqual(calls[0], { kind: 'create', userId: 'user-1', input: { jobType: 'summarize', instruction: 'Summarize', allowedAssetIds: ['asset-1'], outputCategoryId: 'category-1', outputType: 'page', idempotencyKey: 'key-1', locale: 'zh-CN', timezone: 'Asia/Shanghai', capabilities: ['read'], }, }); assert.deepEqual(calls[1], { kind: 'audit', entry: { userId: 'user-1', action: 'agent_access', objectType: 'agent_job', objectId: 'job-1', ip: '10.0.0.3', detail: { jobType: 'summarize', assetIds: ['asset-1'] }, }, }); assert.equal(success.calls.data[0].status, 201); assert.equal(success.calls.data[0].data, job); const failureApi = createRouterRecorder(); const failureError = new Error('create failed'); const failure = createDependencies({ getMindSpaceAgentJobs: () => ({ async createJob() { throw failureError; }, }), }); attachPortalAgentJobRoutes(failureApi, failure.dependencies); const failureReq = createRequest(); const failureRes = createResponseRecorder(); await failureApi.routes.get('POST /mindspace/v1/agent/jobs')( failureReq, failureRes, ); assert.deepEqual(failure.calls.errors[0], { res: failureRes, req: failureReq, error: failureError, }); }); test('get, list, cancel, and retry preserve service forwarding and envelopes', async () => { const calls = []; const agentJobs = { async getJob(userId, jobId) { calls.push({ kind: 'get', userId, jobId }); return { id: jobId }; }, async listJobs(userId, page) { calls.push({ kind: 'list', userId, page }); return { items: [{ id: 'job-1' }], total: 11, offset: page.offset, limit: page.limit, hasMore: true, }; }, async cancelJob(userId, jobId) { calls.push({ kind: 'cancel', userId, jobId }); return { id: jobId, status: 'cancelled' }; }, async retryJob(userId, jobId) { calls.push({ kind: 'retry', userId, jobId }); return { id: jobId, status: 'queued' }; }, }; const api = createRouterRecorder(); const deps = createDependencies({ getMindSpaceAgentJobs: () => agentJobs, }); attachPortalAgentJobRoutes(api, deps.dependencies); await api.routes.get('GET /mindspace/v1/agent/jobs/:jobId')( createRequest({ params: { jobId: 'job-1' } }), createResponseRecorder(), ); const listRes = createResponseRecorder(); await api.routes.get('GET /mindspace/v1/agent/jobs')( createRequest({ query: { limit: '5', offset: '6' } }), listRes, ); await api.routes.get('POST /mindspace/v1/agent/jobs/:jobId/cancel')( createRequest({ params: { jobId: 'job-2' } }), createResponseRecorder(), ); await api.routes.get('POST /mindspace/v1/agent/jobs/:jobId/retry')( createRequest({ params: { jobId: 'job-3' } }), createResponseRecorder(), ); assert.deepEqual(calls, [ { kind: 'get', userId: 'user-1', jobId: 'job-1' }, { kind: 'list', userId: 'user-1', page: { limit: 5, offset: 6 } }, { kind: 'cancel', userId: 'user-1', jobId: 'job-2' }, { kind: 'retry', userId: 'user-1', jobId: 'job-3' }, ]); assert.deepEqual(listRes.body, { data: [{ id: 'job-1' }], page: { total: 11, offset: 6, limit: 5, has_more: true, }, request_id: 'request-1', }); }); test('terminal SSE job emits progress and done without allocating timers', async () => { let timerCalls = 0; const job = { id: 'job-1', status: 'completed', progress: { percent: 100 } }; const api = createRouterRecorder(); const deps = createDependencies({ getMindSpaceAgentJobs: () => ({ getJob: async () => job }), setIntervalFn: () => { timerCalls += 1; return {}; }, }); attachPortalAgentJobRoutes(api, deps.dependencies); const res = createResponseRecorder(); await api.routes.get('GET /mindspace/v1/agent/jobs/:jobId/stream')( createRequest({ params: { jobId: 'job-1' } }), res, ); assert.equal(res.statusCode, 200); assert.equal(res.headers['Content-Type'], 'text/event-stream'); assert.deepEqual(res.writes, [ 'event: progress\n', `data: ${JSON.stringify(job)}\n\n`, 'event: done\n', `data: ${JSON.stringify(job)}\n\n`, ]); assert.equal(res.ended, true); assert.equal(timerCalls, 0); }); test('active SSE job polls changes, emits keepalive, and cleans both timers', async () => { const initial = { id: 'job-1', status: 'running', progress: { percent: 1 } }; const completed = { id: 'job-1', status: 'completed', progress: { percent: 100 }, }; let getCalls = 0; const timers = []; const cleared = []; const api = createRouterRecorder(); const deps = createDependencies({ getMindSpaceAgentJobs: () => ({ async getJob() { getCalls += 1; return getCalls === 1 ? initial : completed; }, }), getSsePollMs: () => 321, setIntervalFn(callback, delay) { const handle = { callback, delay, unrefCalls: 0 }; handle.unref = () => { handle.unrefCalls += 1; }; timers.push(handle); return handle; }, clearIntervalFn(handle) { cleared.push(handle); }, }); attachPortalAgentJobRoutes(api, deps.dependencies); const req = createRequest({ params: { jobId: 'job-1' } }); const res = createResponseRecorder(); await api.routes.get('GET /mindspace/v1/agent/jobs/:jobId/stream')(req, res); assert.deepEqual( timers.map((timer) => timer.delay), [321, 15_000], ); assert.deepEqual( timers.map((timer) => timer.unrefCalls), [1, 1], ); timers[1].callback(); assert.equal(res.writes.at(-1), ': keep-alive\n\n'); await timers[0].callback(); assert.equal(res.ended, true); assert.deepEqual(cleared, timers); assert.match(res.writes.join(''), /event: done/); req.listeners.close(); assert.equal(cleared.length, 2); }); test('run route preserves queued dispatch, existing state, and background error log', async () => { const logs = []; let status = 'completed'; const api = createRouterRecorder(); const deps = createDependencies({ getMindSpaceAgentJobs: () => ({ async getJob(_userId, jobId) { return { id: jobId, status }; }, }), getMindSpaceAgentRunner: () => ({ async runJob(jobId) { assert.equal(jobId, 'job-1'); throw new Error('runner failed'); }, }), logger: { error(...args) { logs.push(args); }, }, }); attachPortalAgentJobRoutes(api, deps.dependencies); const handler = api.routes.get('POST /mindspace/v1/agent/jobs/:jobId/run'); const completedRes = createResponseRecorder(); await handler( createRequest({ params: { jobId: 'job-1' } }), completedRes, ); assert.equal(deps.calls.data[0].data.status, 'completed'); assert.equal(deps.calls.data[0].status, 200); status = 'queued'; const queuedRes = createResponseRecorder(); await handler(createRequest({ params: { jobId: 'job-1' } }), queuedRes); await Promise.resolve(); assert.deepEqual(deps.calls.data[1], { res: queuedRes, req: deps.calls.data[1].req, data: { started: true, jobId: 'job-1' }, status: 202, }); assert.equal(logs.length, 1); assert.equal(logs[0][0], 'MindSpace agent job run failed:'); }); test('internal claim preserves secret gate and claim forwarding', async () => { let allowed = false; let claims = 0; const api = createRouterRecorder(); const deps = createDependencies({ getMindSpaceAgentJobs: () => ({ async claimJob(jobId) { claims += 1; return { id: jobId, token: 'worker-token' }; }, }), requireInternalAgentSecret: (_req, res) => { if (allowed) return true; res.status(401).json({ message: 'denied' }); return false; }, }); attachPortalAgentJobRoutes(api, deps.dependencies); const handler = api.routes.get('POST /internal/agent/jobs/:jobId/claim'); const deniedRes = createResponseRecorder(); await handler(createRequest({ params: { jobId: 'job-1' } }), deniedRes); assert.equal(deniedRes.statusCode, 401); assert.equal(claims, 0); allowed = true; await handler( createRequest({ params: { jobId: 'job-1' } }), createResponseRecorder(), ); assert.equal(claims, 1); assert.equal(deps.calls.data[0].data.token, 'worker-token'); }); test('internal asset preserves token, headers, file read, and binary response', async () => { let received = null; const body = Buffer.from('asset-body'); const api = createRouterRecorder(); const deps = createDependencies({ getMindSpaceAgentJobs: () => ({ async getAssetForJob(...args) { received = args; return { mimeType: 'text/plain', displayName: 'report one.txt', bodyBase64: body.toString('base64'), }; }, }), bearerToken: () => 'worker-token', }); attachPortalAgentJobRoutes(api, deps.dependencies); const res = createResponseRecorder(); await api.routes.get('GET /internal/agent/jobs/:jobId/assets/:assetId')( createRequest({ params: { jobId: 'job-1', assetId: 'asset-1' }, requestId: 'request-asset', }), res, ); assert.deepEqual(received, ['job-1', 'worker-token', 'asset-1']); assert.deepEqual(res.headers, { 'Content-Type': 'text/plain', 'Content-Disposition': 'inline; filename="report%20one.txt"', 'Cache-Control': 'private, no-store', 'X-Request-Id': 'request-asset', }); assert.deepEqual(res.body, body); }); test('heartbeat and complete preserve worker token and payload mapping', async () => { const calls = []; const api = createRouterRecorder(); const deps = createDependencies({ getMindSpaceAgentJobs: () => ({ async heartbeat(...args) { calls.push({ kind: 'heartbeat', args }); return { ok: true }; }, async completeJob(...args) { calls.push({ kind: 'complete', args }); return { id: args[0], status: args[2].status }; }, }), bearerToken: () => 'worker-token', }); attachPortalAgentJobRoutes(api, deps.dependencies); await api.routes.get('POST /internal/agent/jobs/:jobId/heartbeat')( createRequest({ params: { jobId: 'job-1' }, body: { stage: 'render', message: 'working' }, }), createResponseRecorder(), ); await api.routes.get('POST /internal/agent/jobs/:jobId/complete')( createRequest({ params: { jobId: 'job-1' }, body: { status: 'completed', error_code: 'none', error_message: '', output_type: 'page', title: 'Title', summary: 'Summary', content: '', content_format: 'html', page_type: 'report', template_id: 'template-1', source_asset_ids: ['asset-1'], }, }), createResponseRecorder(), ); assert.deepEqual(calls, [ { kind: 'heartbeat', args: ['job-1', 'worker-token', { stage: 'render', message: 'working' }], }, { kind: 'complete', args: [ 'job-1', 'worker-token', { status: 'completed', errorCode: 'none', errorMessage: '', outputType: 'page', title: 'Title', summary: 'Summary', content: '', contentFormat: 'html', pageType: 'report', templateId: 'template-1', sourceAssetIds: ['asset-1'], }, ], }, ]); });