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 createChatSaveStub(overrides = {}) { const calls = []; return { calls, service: { async ensurePreviewThumbnail(input) { calls.push({ kind: 'ensure', input }); return { relativePath: input.relativePath, ready: true }; }, async renderPreviewThumbnailSvg(input) { calls.push({ kind: 'render', input }); return { svg: 'thumb', contentType: 'image/svg+xml; charset=utf-8', relativePath: input.relativePath, }; }, ...overrides, }, }; } function createDependencies(overrides = {}) { const calls = { data: [], errors: [], }; const chatSaveStub = createChatSaveStub( overrides.chatSaveOverrides ?? {}, ); const conversationArtifactCalls = []; const conversationArtifactService = { async registerChatDocxArtifact(input) { conversationArtifactCalls.push(input); return { id: 'docx-1' }; }, }; return { calls, chatSaveStub, conversationArtifactCalls, dependencies: { h5Root: '/workspace', env: {}, getMindSpacePages: () => ({}), getMindSpaceAssets: () => ({}), getMindSpaceChatSave: () => chatSaveStub.service, getConversationArtifactService: () => conversationArtifactService, 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 delegates preview rendering to MindSpace chat save authority', async () => { const api = createRouterRecorder(); const { dependencies, chatSaveStub } = createDependencies({ resolveChatSaveBundle: async () => createBundle({ previewTitle: 'Preview title', previewSummary: 'Preview summary', }), }); attachPortalMindSpaceChatSaveRoutes(api, 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(chatSaveStub.calls[0], { kind: 'render', input: { userId: 'user-1', relativePath: 'public/page.html', html: 'Page', title: 'Preview title', subtitle: 'Preview summary', force: true, }, }); }); test('analyze preserves projection, thumbnail state, scan, and existing page', async () => { const calls = []; const api = createRouterRecorder(); const { dependencies, calls: routeCalls } = createDependencies({ chatSaveOverrides: { async ensurePreviewThumbnail(input) { calls.push({ kind: 'ensure', input }); return { ready: true }; }, }, 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); 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 = routeCalls.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', }); assert.equal(calls[0].kind, 'ensure'); assert.equal(calls[0].input.relativePath, 'public/page.html'); }); 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({ generateDocxBufferFn(input) { calls.push({ kind: 'generate', input }); return buffer; }, getConversationArtifactService: () => ({ async registerChatDocxArtifact(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(calls[1].input.userId, 'user-1'); assert.equal(calls[1].input.sessionId, 'session-1'); assert.equal(calls[1].input.body, buffer); 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, chatSaveStub, calls: routeCalls } = 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: () => ({}), }); attachPortalMindSpaceChatSaveRoutes(api, 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, ); assert.equal(chatSaveStub.calls[0].kind, 'ensure'); 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(routeCalls.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, getMindSpaceChatSave: () => null, getConversationArtifactService: () => 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', ); });