550 lines
15 KiB
JavaScript
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'],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|