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: 'Page', 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 ''; }, }); 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, ''); 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 'thumb'; }, }); attachPortalMindSpaceChatSaveRoutes(api, dependencies.dependencies); const res = createResponseRecorder(); await api.routes.get('GET /mindspace/v1/pages/chat-save-thumbnail')( createRequest(), res, ); assert.equal(res.body, 'thumb'); 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', ); });