Files
memind/server/portal-agent-job-routes.test.mjs
T

550 lines
15 KiB
JavaScript

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',
path: '/tmp/report-one.txt',
};
},
}),
bearerToken: () => 'worker-token',
readFile: async (path) => {
assert.equal(path, '/tmp/report-one.txt');
return body;
},
});
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.equal(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: '<html></html>',
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: '<html></html>',
contentFormat: 'html',
pageType: 'report',
templateId: 'template-1',
sourceAssetIds: ['asset-1'],
},
],
},
]);
});