503 lines
16 KiB
JavaScript
503 lines
16 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { attachPortalMindSpaceChatSaveRoutes } from './portal-mindspace-chat-save-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: {},
|
|
status(code) {
|
|
this.statusCode = code;
|
|
return this;
|
|
},
|
|
json(body) {
|
|
this.body = body;
|
|
return this;
|
|
},
|
|
set(name, value) {
|
|
this.headers[name] = value;
|
|
return this;
|
|
},
|
|
setHeader(name, value) {
|
|
this.headers[name] = value;
|
|
return this;
|
|
},
|
|
send(body) {
|
|
this.body = body;
|
|
return this;
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRequest(overrides = {}) {
|
|
return {
|
|
body: {},
|
|
query: {},
|
|
currentUser: { id: 'user-1', username: 'alice' },
|
|
requestId: 'request-1',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createBundle(overrides = {}) {
|
|
return {
|
|
source: {
|
|
content: 'Assistant content',
|
|
session: { name: 'Session one' },
|
|
message: {
|
|
created: 123,
|
|
role: 'assistant',
|
|
},
|
|
},
|
|
analysis: {
|
|
contentMode: 'static_html',
|
|
links: [{ publicUrl: 'https://example.com/page.html' }],
|
|
selectedLink: { publicUrl: 'https://example.com/page.html' },
|
|
suggestedTitle: 'Analysis title',
|
|
suggestedSummary: 'Analysis summary',
|
|
previewUrl: 'https://example.com/page.html',
|
|
relativePath: 'public/page.html',
|
|
filename: 'page.html',
|
|
},
|
|
resolvedHtml: {
|
|
content: '<html>Page</html>',
|
|
relativePath: 'public/page.html',
|
|
filename: 'page.html',
|
|
absolute: '/workspace/public/page.html',
|
|
suggestedTitle: 'Resolved title',
|
|
suggestedSummary: 'Resolved summary',
|
|
},
|
|
previewTitle: '',
|
|
previewSummary: '',
|
|
previewFrameUrl: '/api/preview',
|
|
thumbnailUrl: '/api/thumbnail',
|
|
localPreviewUrl: '/MindSpace/user/public/page.html',
|
|
localThumbnailUrl: '/MindSpace/user/public/page.thumbnail.svg',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createDependencies(overrides = {}) {
|
|
const calls = {
|
|
data: [],
|
|
errors: [],
|
|
};
|
|
return {
|
|
calls,
|
|
dependencies: {
|
|
h5Root: '/workspace',
|
|
env: {},
|
|
getMindSpacePages: () => ({}),
|
|
getMindSpaceAssets: () => ({}),
|
|
resolveChatSaveBundle: async () => createBundle(),
|
|
resolveExistingSavedPage: async () => null,
|
|
resolveOwnedAssistantMessage: async () => createBundle().source,
|
|
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('chat save module preserves route inventory, order, and DOCX alias handler', () => {
|
|
const api = createRouterRecorder();
|
|
attachPortalMindSpaceChatSaveRoutes(api, createDependencies().dependencies);
|
|
assert.deepEqual([...api.routes.keys()], [
|
|
'GET /mindspace/v1/pages/chat-save-preview',
|
|
'GET /mindspace/v1/pages/chat-save-thumbnail',
|
|
'POST /mindspace/v1/pages/analyze-chat-save',
|
|
'POST /mindspace/v1/pages/chat-save-docx',
|
|
'POST /v1/pages/chat-save-docx',
|
|
'POST /mindspace/v1/pages/save-from-chat',
|
|
]);
|
|
assert.equal(
|
|
api.routes.get('POST /mindspace/v1/pages/chat-save-docx'),
|
|
api.routes.get('POST /v1/pages/chat-save-docx'),
|
|
);
|
|
});
|
|
|
|
test('preview preserves base injection and response headers', async () => {
|
|
const calls = [];
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
resolveChatSaveBundle: async (user, root, input) => {
|
|
calls.push({ kind: 'bundle', user, root, input });
|
|
return createBundle();
|
|
},
|
|
buildWorkspaceBaseHrefFn(userId, relativePath) {
|
|
calls.push({ kind: 'base', userId, relativePath });
|
|
return '/MindSpace/user/public/';
|
|
},
|
|
injectHtmlBaseHrefFn(html, baseHref) {
|
|
calls.push({ kind: 'inject', html, baseHref });
|
|
return '<html><base></html>';
|
|
},
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies);
|
|
const req = createRequest({ query: { session_id: 'session-1' } });
|
|
const res = createResponseRecorder();
|
|
await api.routes.get('GET /mindspace/v1/pages/chat-save-preview')(req, res);
|
|
assert.equal(res.body, '<html><base></html>');
|
|
assert.equal(res.headers['Content-Type'], 'text/html; charset=utf-8');
|
|
assert.equal(res.headers['Cache-Control'], 'private, no-store');
|
|
assert.equal(res.headers['X-Request-Id'], 'request-1');
|
|
assert.equal(calls[0].root, '/workspace');
|
|
assert.deepEqual(calls[1], {
|
|
kind: 'base',
|
|
userId: 'user-1',
|
|
relativePath: 'public/page.html',
|
|
});
|
|
});
|
|
|
|
test('thumbnail preserves title selection, generation options, and response', async () => {
|
|
const calls = [];
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
getAuthPool: () => ({ id: 'pool' }),
|
|
resolveChatSaveBundle: async () =>
|
|
createBundle({
|
|
previewTitle: 'Preview title',
|
|
previewSummary: 'Preview summary',
|
|
}),
|
|
resolveMindSpaceUserPublishDirFn(root, user) {
|
|
calls.push({ kind: 'publish-dir', root, user });
|
|
return '/workspace/user';
|
|
},
|
|
workspaceThumbnailRelativePathFn(relativePath) {
|
|
calls.push({ kind: 'thumb-path', relativePath });
|
|
return 'public/page.thumbnail.svg';
|
|
},
|
|
async ensureWorkspaceHtmlThumbnailFn(...args) {
|
|
calls.push({ kind: 'ensure', args });
|
|
},
|
|
resolveMindSpaceRuntimeConfigFn() {
|
|
return { storageRoot: '/storage' };
|
|
},
|
|
createAssetDataUriResolverFn(pool, storageRoot, userId) {
|
|
calls.push({ kind: 'resolver', pool, storageRoot, userId });
|
|
return async () => null;
|
|
},
|
|
async generateHtmlThumbnailFn(...args) {
|
|
calls.push({ kind: 'generate', args });
|
|
return '<svg>thumb</svg>';
|
|
},
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies);
|
|
const res = createResponseRecorder();
|
|
await api.routes.get('GET /mindspace/v1/pages/chat-save-thumbnail')(
|
|
createRequest(),
|
|
res,
|
|
);
|
|
assert.equal(res.body, '<svg>thumb</svg>');
|
|
assert.equal(res.headers['Content-Type'], 'image/svg+xml; charset=utf-8');
|
|
assert.deepEqual(calls.find((call) => call.kind === 'resolver'), {
|
|
kind: 'resolver',
|
|
pool: { id: 'pool' },
|
|
storageRoot: '/storage',
|
|
userId: 'user-1',
|
|
});
|
|
const generateOptions = calls.find((call) => call.kind === 'generate').args[3];
|
|
assert.equal(generateOptions.title, 'Preview title');
|
|
assert.equal(generateOptions.subtitle, 'Preview summary');
|
|
assert.equal(generateOptions.contentBaseDir, '/workspace/public');
|
|
assert.equal(generateOptions.force, true);
|
|
});
|
|
|
|
test('analyze preserves projection, thumbnail state, scan, and existing page', async () => {
|
|
const calls = [];
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
async ensureWorkspaceHtmlThumbnailFn() {
|
|
calls.push('thumbnail');
|
|
},
|
|
resolveMindSpaceUserPublishDirFn: () => '/workspace/user',
|
|
async resolveExistingSavedPage(userId, input) {
|
|
calls.push({ userId, input });
|
|
return {
|
|
id: 'page-1',
|
|
title: 'Saved',
|
|
categoryCode: 'draft',
|
|
hidden: true,
|
|
};
|
|
},
|
|
scanContentFn(content) {
|
|
calls.push({ scan: content });
|
|
return { blocked: false };
|
|
},
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies);
|
|
const req = createRequest({
|
|
body: { session_id: 'session-1', message_id: 'message-1' },
|
|
});
|
|
await api.routes.get('POST /mindspace/v1/pages/analyze-chat-save')(
|
|
req,
|
|
createResponseRecorder(),
|
|
);
|
|
const data = dependencies.calls.data[0].data;
|
|
assert.equal(data.contentMode, 'static_html');
|
|
assert.equal(data.selectedLinkIndex, 0);
|
|
assert.equal(data.suggestedTitle, 'Resolved title');
|
|
assert.equal(data.thumbnailReady, true);
|
|
assert.equal(data.hasHtmlContent, true);
|
|
assert.deepEqual(data.privacyScan, { blocked: false });
|
|
assert.deepEqual(data.existingPage, {
|
|
id: 'page-1',
|
|
title: 'Saved',
|
|
categoryCode: 'draft',
|
|
});
|
|
});
|
|
|
|
test('DOCX route preserves artifact registration, headers, and non-fatal registry errors', async () => {
|
|
const calls = [];
|
|
const warnings = [];
|
|
const api = createRouterRecorder();
|
|
const buffer = Buffer.from('docx');
|
|
const dependencies = createDependencies({
|
|
getConversationPackageRegistry: () => ({ id: 'registry' }),
|
|
generateDocxBufferFn(input) {
|
|
calls.push({ kind: 'generate', input });
|
|
return buffer;
|
|
},
|
|
async registerChatDocxArtifactFn(input) {
|
|
calls.push({ kind: 'register', input });
|
|
throw new Error('registry unavailable');
|
|
},
|
|
logger: {
|
|
warn(...args) {
|
|
warnings.push(args);
|
|
},
|
|
},
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies);
|
|
const req = createRequest({ body: { session_id: 'session-1' } });
|
|
const res = createResponseRecorder();
|
|
await api.routes.get('POST /mindspace/v1/pages/chat-save-docx')(req, res);
|
|
assert.equal(res.body, buffer);
|
|
assert.equal(
|
|
calls[0].input.title,
|
|
'Resolved title',
|
|
);
|
|
assert.equal(calls[0].input.contentFormat, 'html');
|
|
assert.equal(
|
|
res.headers['Content-Type'],
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
);
|
|
assert.match(res.headers['Content-Disposition'], /Resolved-title\.docx/);
|
|
assert.equal(warnings.length, 1);
|
|
});
|
|
|
|
test('save-from-chat preserves asset export and draft create paths', async () => {
|
|
const source = createBundle({
|
|
source: {
|
|
content: 'Markdown content',
|
|
session: { name: 'Session one' },
|
|
message: { created: 123, role: 'assistant' },
|
|
},
|
|
}).source;
|
|
const assetCalls = [];
|
|
const assetApi = createRouterRecorder();
|
|
const assetDependencies = createDependencies({
|
|
resolveOwnedAssistantMessage: async () => source,
|
|
analyzeChatMessageForSaveFn: () => ({
|
|
contentMode: 'markdown',
|
|
previewUrl: null,
|
|
relativePath: null,
|
|
}),
|
|
scanContentFn: () => ({ blocked: false }),
|
|
getMindSpaceAssets: () => ({
|
|
async createChatAsset(userId, input) {
|
|
assetCalls.push({ userId, input });
|
|
return { id: 'asset-1' };
|
|
},
|
|
}),
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(
|
|
assetApi,
|
|
assetDependencies.dependencies,
|
|
);
|
|
const assetRes = createResponseRecorder();
|
|
await assetApi.routes.get('POST /mindspace/v1/pages/save-from-chat')(
|
|
createRequest({
|
|
body: {
|
|
session_id: 'session-1',
|
|
message_id: 'message-1',
|
|
category_code: 'oa',
|
|
title: 'Export title',
|
|
},
|
|
}),
|
|
assetRes,
|
|
);
|
|
assert.equal(assetRes.statusCode, 201);
|
|
assert.equal(assetRes.body.data.kind, 'asset');
|
|
assert.equal(assetCalls[0].input.filename, 'Export-title.md');
|
|
assert.equal(assetCalls[0].input.buffer.toString(), 'Markdown content');
|
|
|
|
const pageCalls = [];
|
|
const pageApi = createRouterRecorder();
|
|
const pageDependencies = createDependencies({
|
|
resolveOwnedAssistantMessage: async () => source,
|
|
analyzeChatMessageForSaveFn: () => ({
|
|
contentMode: 'markdown',
|
|
previewUrl: null,
|
|
relativePath: null,
|
|
}),
|
|
scanContentFn: () => ({ blocked: false }),
|
|
getMindSpacePages: () => ({
|
|
async createFromChat(userId, input, metadata) {
|
|
pageCalls.push({ userId, input, metadata });
|
|
return { id: 'page-1' };
|
|
},
|
|
}),
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(
|
|
pageApi,
|
|
pageDependencies.dependencies,
|
|
);
|
|
const pageRes = createResponseRecorder();
|
|
await pageApi.routes.get('POST /mindspace/v1/pages/save-from-chat')(
|
|
createRequest({
|
|
body: {
|
|
session_id: 'session-1',
|
|
message_id: 'message-1',
|
|
category_code: 'draft',
|
|
title: 'Draft',
|
|
},
|
|
}),
|
|
pageRes,
|
|
);
|
|
assert.equal(pageRes.statusCode, 201);
|
|
assert.equal(pageRes.body.data.kind, 'page');
|
|
assert.equal(pageCalls[0].input.contentFormat, 'markdown');
|
|
assert.deepEqual(pageCalls[0].metadata, {
|
|
sessionId: 'session-1',
|
|
messageId: 'message-1',
|
|
snapshot: {
|
|
session_name: 'Session one',
|
|
message_created: 123,
|
|
role: 'assistant',
|
|
content_mode: 'markdown',
|
|
public_url: null,
|
|
relative_path: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
test('save-from-chat preserves HTML replacement and error mapping', async () => {
|
|
const calls = [];
|
|
const pages = {
|
|
async findPageByRelativePath(userId, relativePath) {
|
|
calls.push({ kind: 'find', userId, relativePath });
|
|
return { id: 'page-existing' };
|
|
},
|
|
async getPage(userId, pageId) {
|
|
calls.push({ kind: 'get', userId, pageId });
|
|
return { versionNo: 4 };
|
|
},
|
|
async updatePage(userId, pageId, input) {
|
|
calls.push({ kind: 'update', userId, pageId, input });
|
|
return { id: pageId, versionNo: 5 };
|
|
},
|
|
};
|
|
const api = createRouterRecorder();
|
|
const dependencies = createDependencies({
|
|
getMindSpacePages: () => pages,
|
|
resolveOwnedAssistantMessage: async () => createBundle().source,
|
|
analyzeChatMessageForSaveFn: () => ({
|
|
contentMode: 'static_html',
|
|
previewUrl: 'https://example.com/page.html',
|
|
relativePath: 'public/page.html',
|
|
}),
|
|
resolveStaticHtmlContentFn: async () => createBundle().resolvedHtml,
|
|
normalizeWorkspaceRelativePathFn: (value) => value,
|
|
scanContentFn: () => ({}),
|
|
resolveMindSpaceUserPublishDirFn: () => '/workspace/user',
|
|
ensureWorkspaceHtmlThumbnailFn: async () => {},
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies);
|
|
const res = createResponseRecorder();
|
|
await api.routes.get('POST /mindspace/v1/pages/save-from-chat')(
|
|
createRequest({
|
|
body: {
|
|
session_id: 'session-1',
|
|
message_id: 'message-1',
|
|
category_code: 'draft',
|
|
},
|
|
}),
|
|
res,
|
|
);
|
|
assert.equal(res.body.data.page.id, 'page-existing');
|
|
assert.equal(
|
|
calls.find((call) => call.kind === 'update').input.expectedVersion,
|
|
4,
|
|
);
|
|
|
|
const invalidRes = createResponseRecorder();
|
|
await api.routes.get('POST /mindspace/v1/pages/save-from-chat')(
|
|
createRequest({ body: { category_code: 'invalid' } }),
|
|
invalidRes,
|
|
);
|
|
assert.equal(invalidRes.statusCode, 418);
|
|
assert.equal(dependencies.calls.errors.at(-1).error.code, 'invalid_category_code');
|
|
});
|
|
|
|
test('chat save routes preserve unavailable and missing HTML errors', async () => {
|
|
const unavailableApi = createRouterRecorder();
|
|
const unavailableDependencies = createDependencies({
|
|
getMindSpacePages: () => null,
|
|
getMindSpaceAssets: () => null,
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(
|
|
unavailableApi,
|
|
unavailableDependencies.dependencies,
|
|
);
|
|
for (const route of [
|
|
'GET /mindspace/v1/pages/chat-save-preview',
|
|
'GET /mindspace/v1/pages/chat-save-thumbnail',
|
|
'POST /mindspace/v1/pages/analyze-chat-save',
|
|
'POST /mindspace/v1/pages/chat-save-docx',
|
|
'POST /mindspace/v1/pages/save-from-chat',
|
|
]) {
|
|
const res = createResponseRecorder();
|
|
await unavailableApi.routes.get(route)(createRequest(), res);
|
|
assert.equal(res.statusCode, 503, route);
|
|
}
|
|
|
|
const missingApi = createRouterRecorder();
|
|
const missingDependencies = createDependencies({
|
|
resolveChatSaveBundle: async () =>
|
|
createBundle({ resolvedHtml: null }),
|
|
});
|
|
attachPortalMindSpaceChatSaveRoutes(
|
|
missingApi,
|
|
missingDependencies.dependencies,
|
|
);
|
|
await missingApi.routes.get('GET /mindspace/v1/pages/chat-save-preview')(
|
|
createRequest(),
|
|
createResponseRecorder(),
|
|
);
|
|
assert.equal(
|
|
missingDependencies.calls.errors[0].error.code,
|
|
'static_page_not_found',
|
|
);
|
|
});
|