import assert from 'node:assert/strict'; import test from 'node:test'; import { attachPortalSessionRoutes } from './portal-session-routes.mjs'; function createRouterRecorder() { const routes = new Map(); return { routes, get(path, handler) { routes.set(`GET ${path}`, handler); }, delete(path, handler) { routes.set(`DELETE ${path}`, handler); }, }; } function createResponseRecorder() { return { statusCode: 200, body: undefined, ended: false, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, send(body) { this.body = body; return this; }, end() { this.ended = true; return this; }, }; } function createRequest(overrides = {}) { return { params: { sessionId: 'session-1' }, query: {}, currentUser: { id: 'user-1', username: 'alice', }, ...overrides, }; } function createSnapshot(overrides = {}) { return { meta: { synced_msg_count: 2, source_updated_at: 'updated-1', }, session: { id: 'session-1', name: 'Session', }, messages: [ { id: 'message-1', role: 'assistant', content: 'Hello', metadata: { userVisible: true }, }, ], ...overrides, }; } function createUpstream({ ok = true, status = 200, body = { id: 'session-1', conversation: [] }, text = '', } = {}) { return { ok, status, async json() { return body; }, async text() { return text; }, }; } function createDependencies(overrides = {}) { const calls = { waits: 0, owns: [], unregister: [], begin: [], end: [], syncPages: [], artifacts: [], }; const proxy = { async resolveTarget(sessionId) { return `target:${sessionId}`; }, async apiFetchTo() { return createUpstream(); }, proxySessionEvents(_req, _res, sessionId, hooks) { return { sessionId, hooks }; }, }; return { calls, proxy, dependencies: { h5Root: '/workspace', env: {}, async waitForUserAuthReady() { calls.waits += 1; }, getUserAuth: () => ({}), getTkmindProxy: () => proxy, getSessionSnapshotService: () => null, getAuthPool: () => null, getMindSpaceAssets: () => null, getMemoryV2: () => null, getMindSpaceAnalyticsConfig: () => ({}), isWorkspaceMaintenanceEnabled: () => false, async ownsAgentSession(userId, sessionId) { calls.owns.push({ userId, sessionId }); return true; }, async unregisterAgentSessionForUser(userId, sessionId) { calls.unregister.push({ userId, sessionId }); }, beginSessionPageDelivery(sessionId) { calls.begin.push(sessionId); }, endSessionPageDelivery(sessionId) { calls.end.push(sessionId); }, async syncUserGeneratedPages(userId, options) { calls.syncPages.push({ userId, options }); }, async registerPublicHtmlArtifactsForConversation(input) { calls.artifacts.push(input); }, isDirectChatSessionIdFn: () => false, isPortalDirectChatSnapshotFn: () => false, shouldExpirePortalDirectChatSnapshotFn: async () => false, sanitizeSessionConversationFn: (messages) => messages, repairSessionConversationFromDbFn: async ( _pool, session, ) => session, filterNonemptyUserVisibleMessagesFn: (messages) => messages, resolveMindSpaceUserPublishDirFn: () => '/workspace/user-1', collectPublicHtmlWritePathsFn: () => [], preparePageDeliveryContractFn: async () => ({}), materializePublicHtmlWritesFn: () => {}, sendMindSpaceAnalyticsEventFn: async () => {}, resolveAnalyticsOwnerSegmentFn: () => 'segment', resolveAnalyticsOwnerLabelFn: () => 'label', resolveAnalyticsPlanFn: () => 'pro', syncPublicHtmlAfterFinishFn: async () => ({ publicHtmlRelativePaths: [], }), resolveMindSpaceRuntimeConfigFn: () => ({ storageRoot: '/storage', }), maybeRepairH5HtmlAfterFinishFn: async () => ({ skipped: 'ok', }), maybeRepairPageDataAfterFinishFn: async () => ({ skipped: 'not_page_data', }), markPageDeliveryContractReadyFn: async () => true, fileExists: () => true, resolvePath: (...parts) => parts.join('/'), publishRootDir: 'MindSpace', logger: { warn() {}, }, ...overrides, }, }; } test('session module preserves route inventory and order', () => { const api = createRouterRecorder(); attachPortalSessionRoutes(api, createDependencies().dependencies); assert.deepEqual([...api.routes.keys()], [ 'GET /sessions/:sessionId', 'DELETE /sessions/:sessionId', 'GET /sessions/:sessionId/events', ]); }); test('session detail uses ordinary snapshot only when both hints match', async () => { const snapshot = createSnapshot(); let upstreamCalls = 0; const snapshotService = { isEnabled: () => true, get: async () => snapshot, save: async () => {}, }; const setup = createDependencies({ getSessionSnapshotService: () => snapshotService, getTkmindProxy: () => ({ async resolveTarget() { return 'goose'; }, async apiFetchTo() { upstreamCalls += 1; return createUpstream({ body: { id: 'session-1', source: 'goose', conversation: [], }, }); }, }), }); const api = createRouterRecorder(); attachPortalSessionRoutes(api, setup.dependencies); const handler = api.routes.get('GET /sessions/:sessionId'); const noHintsRes = createResponseRecorder(); await handler(createRequest(), noHintsRes, () => {}); assert.equal(noHintsRes.body.source, 'goose'); assert.equal(upstreamCalls, 1); const matchedRes = createResponseRecorder(); await handler( createRequest({ query: { hint_mc: '2', hint_ua: 'updated-1', }, }), matchedRes, () => {}, ); assert.equal(matchedRes.body.name, 'Session'); assert.equal(matchedRes.body.conversation.length, 1); assert.equal(upstreamCalls, 1); }); test('direct and portal-direct snapshots bypass ordinary cache hints', async () => { for (const directMode of ['session-id', 'snapshot']) { const api = createRouterRecorder(); let upstreamCalls = 0; const setup = createDependencies({ getSessionSnapshotService: () => ({ isEnabled: () => true, get: async () => createSnapshot(), }), isDirectChatSessionIdFn: () => directMode === 'session-id', isPortalDirectChatSnapshotFn: () => directMode === 'snapshot', getTkmindProxy: () => ({ async resolveTarget() { return 'goose'; }, async apiFetchTo() { upstreamCalls += 1; return createUpstream(); }, }), }); attachPortalSessionRoutes(api, setup.dependencies); const res = createResponseRecorder(); await api.routes.get('GET /sessions/:sessionId')( createRequest(), res, () => {}, ); assert.equal(res.body.name, 'Session'); assert.equal(upstreamCalls, 0); } }); test('session detail expires stale portal snapshot and writes through Goose result', async () => { const calls = []; const snapshotService = { isEnabled: () => true, get: async () => createSnapshot(), async remove(sessionId) { calls.push({ kind: 'remove', sessionId }); }, async save(sessionId, userId, session, messages) { calls.push({ kind: 'save', sessionId, userId, session, messages, }); }, }; const api = createRouterRecorder(); const setup = createDependencies({ getAuthPool: () => ({ id: 'pool' }), getSessionSnapshotService: () => snapshotService, shouldExpirePortalDirectChatSnapshotFn: async () => true, sanitizeSessionConversationFn(messages) { calls.push({ kind: 'sanitize', messages }); return messages.map((message) => ({ ...message, sanitized: true, })); }, async repairSessionConversationFromDbFn( pool, session, sessionId, userId, ) { calls.push({ kind: 'repair', pool, sessionId, userId, }); return { ...session, repaired: true }; }, getTkmindProxy: () => ({ async resolveTarget() { return 'goose'; }, async apiFetchTo() { return createUpstream({ body: { id: 'session-1', conversation: [ { id: 'message-1', metadata: { userVisible: true }, }, ], }, }); }, }), }); attachPortalSessionRoutes(api, setup.dependencies); const res = createResponseRecorder(); await api.routes.get('GET /sessions/:sessionId')( createRequest(), res, () => {}, ); await Promise.resolve(); assert.equal(res.body.repaired, true); assert.deepEqual(calls.map((call) => call.kind), [ 'remove', 'sanitize', 'repair', 'save', ]); }); test('session routes preserve ownership and unavailable fallthrough', async () => { const unavailableApi = createRouterRecorder(); let nextCalls = 0; const unavailable = createDependencies({ getUserAuth: () => null, }); attachPortalSessionRoutes( unavailableApi, unavailable.dependencies, ); await unavailableApi.routes.get('GET /sessions/:sessionId')( createRequest(), createResponseRecorder(), () => { nextCalls += 1; }, ); assert.equal(nextCalls, 1); const deniedApi = createRouterRecorder(); const denied = createDependencies({ ownsAgentSession: async () => false, }); attachPortalSessionRoutes(deniedApi, denied.dependencies); const deniedRes = createResponseRecorder(); await deniedApi.routes.get('GET /sessions/:sessionId')( createRequest(), deniedRes, () => {}, ); assert.equal(deniedRes.statusCode, 403); }); test('session delete preserves direct and Goose deletion behavior', async () => { for (const direct of [true, false]) { const removed = []; const api = createRouterRecorder(); const setup = createDependencies({ isDirectChatSessionIdFn: () => direct, getSessionSnapshotService: () => ({ async remove(sessionId) { removed.push(sessionId); }, }), getTkmindProxy: () => ({ async resolveTarget(sessionId) { assert.equal(direct, false); return `target:${sessionId}`; }, async apiFetchTo(target, pathname, init) { assert.equal(target, 'target:session-1'); assert.equal(pathname, '/sessions/session-1'); assert.equal(init.method, 'DELETE'); return createUpstream({ ok: false, status: 404 }); }, }), }); attachPortalSessionRoutes(api, setup.dependencies); const res = createResponseRecorder(); await api.routes.get('DELETE /sessions/:sessionId')( createRequest(), res, () => {}, ); await Promise.resolve(); assert.equal(res.statusCode, 204); assert.equal(res.ended, true); assert.deepEqual(setup.calls.unregister, [ { userId: 'user-1', sessionId: 'session-1' }, ]); assert.deepEqual(removed, ['session-1']); } }); test('direct session events preserve snapshot and 404 behavior', async () => { const snapshot = createSnapshot(); const api = createRouterRecorder(); const setup = createDependencies({ isDirectChatSessionIdFn: () => true, getSessionSnapshotService: () => ({ get: async () => snapshot, }), sendDirectChatSessionEventsFn(req, res, received) { assert.equal(received, snapshot); return res.status(209).json({ direct: req.params.sessionId }); }, }); attachPortalSessionRoutes(api, setup.dependencies); const res = createResponseRecorder(); await api.routes.get('GET /sessions/:sessionId/events')( createRequest(), res, () => {}, ); assert.equal(res.statusCode, 209); assert.equal(res.body.direct, 'session-1'); const missingApi = createRouterRecorder(); const missing = createDependencies({ isDirectChatSessionIdFn: () => true, getSessionSnapshotService: () => ({ get: async () => null, }), }); attachPortalSessionRoutes(missingApi, missing.dependencies); const missingRes = createResponseRecorder(); await missingApi.routes.get('GET /sessions/:sessionId/events')( createRequest(), missingRes, () => {}, ); assert.equal(missingRes.statusCode, 404); }); test('stream hook uses current session id for contracts and analytics', async () => { const calls = []; let captured = null; const api = createRouterRecorder(); const setup = createDependencies({ getAuthPool: () => ({ id: 'pool' }), collectPublicHtmlWritePathsFn: () => [ 'public/page.html', ], preparePageDeliveryContractFn(input) { calls.push({ kind: 'contract', input }); return Promise.resolve({}); }, materializePublicHtmlWritesFn(event, options) { calls.push({ kind: 'materialize', event, options }); }, sendMindSpaceAnalyticsEventFn(input) { calls.push({ kind: 'analytics', input }); return Promise.resolve(); }, getTkmindProxy: () => ({ proxySessionEvents(_req, _res, sessionId, hooks) { captured = { sessionId, hooks }; return captured; }, }), }); attachPortalSessionRoutes(api, setup.dependencies); await api.routes.get('GET /sessions/:sessionId/events')( createRequest(), createResponseRecorder(), () => {}, ); captured.hooks.onEvent({ type: 'Message', message: { role: 'user', metadata: { memindRun: { pgRequired: true } }, }, }); await Promise.resolve(); assert.equal(calls[0].input.requestId, 'session-1'); assert.equal(calls[0].input.pgRequired, true); assert.equal(calls[1].kind, 'materialize'); assert.equal(calls[2].input.publicationId, 'session-1'); assert.equal(calls[2].input.agentRunId, 'session-1'); assert.equal(calls[2].input.planType, 'pro'); assert.match( calls[2].input.generatedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, ); }); test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock lifecycle', async () => { const calls = []; let hooks = null; const userMessage = { id: 'user-message', role: 'user', content: [{ type: 'text', text: ' build page ' }], metadata: { userVisible: true, memindRun: { pgRequired: true }, }, }; const snapshotService = { isEnabled: () => true, async get() { return { messages: [userMessage] }; }, async refresh(sessionId, userId, apiFetchFn) { calls.push({ kind: 'refresh', sessionId, userId }); assert.equal(typeof apiFetchFn, 'function'); }, }; const setup = createDependencies({ getAuthPool: () => ({ id: 'pool' }), getSessionSnapshotService: () => snapshotService, getMemoryV2: () => ({ async observePersonalMemory(input) { calls.push({ kind: 'memory', input }); }, }), getTkmindProxy: () => ({ async resolveTarget(sessionId) { return `target:${sessionId}`; }, async apiFetchTo() { return createUpstream(); }, proxySessionEvents(_req, _res, _sessionId, receivedHooks) { hooks = receivedHooks; }, }), beginSessionPageDelivery(sessionId) { calls.push({ kind: 'begin', sessionId }); }, endSessionPageDelivery(sessionId) { calls.push({ kind: 'end', sessionId }); }, async syncPublicHtmlAfterFinishFn(input) { calls.push({ kind: 'sync-html', input }); await input.registerPublicHtmlArtifacts('user-1', { sessionId: 'session-1', relativePaths: ['public/page.html'], }); return { publicHtmlRelativePaths: ['public/page.html'], docxSync: { missing: [] }, }; }, async maybeRepairH5HtmlAfterFinishFn(input) { calls.push({ kind: 'repair-html', input }); return { skipped: 'ok' }; }, async syncUserGeneratedPages(userId, options) { calls.push({ kind: 'sync-pages', userId, options }); }, async maybeRepairPageDataAfterFinishFn(input) { calls.push({ kind: 'repair-page-data', input }); return { skipped: 'not_page_data' }; }, async preparePageDeliveryContractFn(input) { calls.push({ kind: 'prepare-contract', input }); return {}; }, async markPageDeliveryContractReadyFn(input) { calls.push({ kind: 'ready', input }); return true; }, async registerPublicHtmlArtifactsForConversation(input) { calls.push({ kind: 'artifacts', input }); }, }); const api = createRouterRecorder(); attachPortalSessionRoutes(api, setup.dependencies); await api.routes.get('GET /sessions/:sessionId/events')( createRequest(), createResponseRecorder(), () => {}, ); await hooks.onAfterFinish('session-1', 'user-1'); assert.equal(calls[0].kind, 'begin'); assert.equal(calls.at(-1).kind, 'end'); assert.deepEqual( calls .filter((call) => [ 'refresh', 'sync-html', 'artifacts', 'repair-html', 'sync-pages', 'repair-page-data', 'prepare-contract', 'ready', 'memory', ].includes(call.kind), ) .map((call) => call.kind), [ 'refresh', 'sync-html', 'artifacts', 'repair-html', 'sync-pages', 'repair-page-data', 'prepare-contract', 'ready', 'memory', ], ); assert.equal( calls.find((call) => call.kind === 'repair-page-data').input .userText, 'build page', ); }); test('Finish marks streamed HTML contracts ready when final sync omits one path', async () => { let hooks = null; const readyPaths = []; const preparedPaths = []; const setup = createDependencies({ getAuthPool: () => ({ id: 'pool' }), getTkmindProxy: () => ({ async resolveTarget(sessionId) { return `target:${sessionId}`; }, async apiFetchTo() { return createUpstream(); }, proxySessionEvents(_req, _res, _sessionId, receivedHooks) { hooks = receivedHooks; }, }), collectPublicHtmlWritePathsFn: () => [ 'public/shop-admin.html', ], async preparePageDeliveryContractFn(input) { preparedPaths.push(input.relativePath); return {}; }, async syncPublicHtmlAfterFinishFn() { return { publicHtmlRelativePaths: ['public/shop.html'], docxSync: { missing: [] }, }; }, async markPageDeliveryContractReadyFn(input) { readyPaths.push(input.relativePath); return true; }, }); const api = createRouterRecorder(); attachPortalSessionRoutes(api, setup.dependencies); await api.routes.get('GET /sessions/:sessionId/events')( createRequest(), createResponseRecorder(), () => {}, ); hooks.onEvent({ type: 'Message', message: { role: 'assistant', content: [], }, }); await hooks.onAfterFinish('session-1', 'user-1'); assert.ok( preparedPaths.includes('public/shop-admin.html'), ); assert.deepEqual(readyPaths, [ 'public/shop-admin.html', 'public/shop.html', ]); });