Files
memind/server/portal-mindspace-chat-save-routes.test.mjs

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',
);
});