import assert from 'node:assert/strict'; import test from 'node:test'; import { attachPortalUserMemoryRoutes } from './portal-user-memory-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); }, delete(path, handler) { routes.set(`DELETE ${path}`, handler); }, }; } function createResponseRecorder() { return { statusCode: 200, body: undefined, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, }; } function createRequest(overrides = {}) { return { body: {}, query: {}, params: {}, currentUser: { id: 'user-1' }, ...overrides, }; } function enabledMemoryV2(overrides = {}) { return { getStatus: async () => ({ enabled: true }), ...overrides, }; } function createDependencies(overrides = {}) { return { getMemoryV2: () => enabledMemoryV2(), getTkmindProxy: () => ({}), ensureUserMemoryCapability: async () => ({ unrestricted: true }), ownsAgentSession: async () => true, loadUserVisibleConversation: async () => [], syncUserMemoriesIntoSession: async () => true, resolveUserMemoryItems: async () => [], ...overrides, }; } test('User Memory module preserves route inventory and order', () => { const api = createRouterRecorder(); attachPortalUserMemoryRoutes(api); assert.deepEqual([...api.routes.keys()], [ 'POST /user-memory/v1/remember-recent', 'POST /user-memory/v1/sync', 'GET /user-memory/v1/items', 'DELETE /user-memory/v1/items/:memoryId', ]); }); test('remember-recent preserves memory, proxy, and capability gates', async () => { const disabledApi = createRouterRecorder(); attachPortalUserMemoryRoutes(disabledApi, { getMemoryV2: () => ({ async getStatus() { throw new Error('status failed'); }, }), }); const disabledRes = createResponseRecorder(); await disabledApi.routes.get('POST /user-memory/v1/remember-recent')( createRequest(), disabledRes, ); assert.equal(disabledRes.statusCode, 503); assert.deepEqual(disabledRes.body, { message: '长期记忆功能未启用' }); const proxyApi = createRouterRecorder(); attachPortalUserMemoryRoutes( proxyApi, createDependencies({ getTkmindProxy: () => null }), ); const proxyRes = createResponseRecorder(); await proxyApi.routes.get('POST /user-memory/v1/remember-recent')( createRequest(), proxyRes, ); assert.equal(proxyRes.statusCode, 503); assert.deepEqual(proxyRes.body, { message: '会话代理尚未就绪' }); let ownershipChecked = false; const capabilityApi = createRouterRecorder(); attachPortalUserMemoryRoutes( capabilityApi, createDependencies({ ensureUserMemoryCapability: async (_req, res) => { res.status(403).json({ message: 'memory denied' }); return null; }, ownsAgentSession: async () => { ownershipChecked = true; return true; }, }), ); const capabilityRes = createResponseRecorder(); await capabilityApi.routes.get('POST /user-memory/v1/remember-recent')( createRequest({ body: { sessionId: 'session-1' } }), capabilityRes, ); assert.equal(capabilityRes.statusCode, 403); assert.equal(ownershipChecked, false); }); test('remember-recent preserves session validation and ownership gate', async () => { const missingApi = createRouterRecorder(); attachPortalUserMemoryRoutes(missingApi, createDependencies()); const missingRes = createResponseRecorder(); await missingApi.routes.get('POST /user-memory/v1/remember-recent')( createRequest({ body: { sessionId: ' ' } }), missingRes, ); assert.equal(missingRes.statusCode, 400); assert.deepEqual(missingRes.body, { message: '缺少 sessionId' }); let ownershipInput = null; const deniedApi = createRouterRecorder(); attachPortalUserMemoryRoutes( deniedApi, createDependencies({ ownsAgentSession: async (...args) => { ownershipInput = args; return false; }, }), ); const deniedRes = createResponseRecorder(); await deniedApi.routes.get('POST /user-memory/v1/remember-recent')( createRequest({ body: { sessionId: ' session-1 ' } }), deniedRes, ); assert.deepEqual(ownershipInput, ['user-1', 'session-1']); assert.equal(deniedRes.statusCode, 403); assert.deepEqual(deniedRes.body, { message: '无权访问该会话' }); }); test('remember-recent preserves write, sync, resolve order and response projection', async () => { const calls = []; const messages = [{ role: 'user', content: 'remember this' }]; const api = createRouterRecorder(); attachPortalUserMemoryRoutes( api, createDependencies({ getMemoryV2: () => enabledMemoryV2({ async write(input) { calls.push({ kind: 'write', input }); return { analyzed: 3, memories: 2 }; }, }), loadUserVisibleConversation: async (...args) => { calls.push({ kind: 'load', args }); return messages; }, syncUserMemoriesIntoSession: async (...args) => { calls.push({ kind: 'sync', args }); return true; }, resolveUserMemoryItems: async (...args) => { calls.push({ kind: 'resolve', args }); return [{ id: 'm1' }, { id: 'm2' }, { id: 'm3' }]; }, }), ); const res = createResponseRecorder(); await api.routes.get('POST /user-memory/v1/remember-recent')( createRequest({ body: { sessionId: 'session-1' } }), res, ); assert.deepEqual(calls, [ { kind: 'load', args: ['session-1', 'user-1'] }, { kind: 'write', input: { userId: 'user-1', sessionId: 'session-1', messages }, }, { kind: 'sync', args: ['user-1', 'session-1'] }, { kind: 'resolve', args: ['user-1', { sessionId: 'session-1', limit: 200 }], }, ]); assert.deepEqual(res.body, { ok: true, analyzed: 3, memories: 2, totalMemories: 3, syncedToSession: true, }); }); test('remember-recent preserves operation error response', async () => { const api = createRouterRecorder(); attachPortalUserMemoryRoutes( api, createDependencies({ loadUserVisibleConversation: async () => { throw new Error('conversation unavailable'); }, }), ); const res = createResponseRecorder(); await api.routes.get('POST /user-memory/v1/remember-recent')( createRequest({ body: { sessionId: 'session-1' } }), res, ); assert.equal(res.statusCode, 500); assert.deepEqual(res.body, { message: 'conversation unavailable' }); }); test('sync preserves compact flow, defaults, and operation errors', async () => { const calls = []; const api = createRouterRecorder(); attachPortalUserMemoryRoutes( api, createDependencies({ getMemoryV2: () => enabledMemoryV2({ async compact(input) { calls.push({ kind: 'compact', input }); return {}; }, }), syncUserMemoriesIntoSession: async (...args) => { calls.push({ kind: 'sync', args }); return false; }, resolveUserMemoryItems: async (...args) => { calls.push({ kind: 'resolve', args }); return [{ id: 'm1' }]; }, }), ); const res = createResponseRecorder(); await api.routes.get('POST /user-memory/v1/sync')( createRequest({ body: { sessionId: 'session-2' } }), res, ); assert.deepEqual(calls, [ { kind: 'compact', input: { userId: 'user-1', sessionId: 'session-2' }, }, { kind: 'sync', args: ['user-1', 'session-2'] }, { kind: 'resolve', args: ['user-1', { sessionId: 'session-2', limit: 200 }], }, ]); assert.deepEqual(res.body, { ok: true, analyzed: 0, memories: 0, totalMemories: 1, syncedToSession: false, }); const failureApi = createRouterRecorder(); attachPortalUserMemoryRoutes( failureApi, createDependencies({ getMemoryV2: () => enabledMemoryV2({ async compact() { throw 'compact failed'; }, }), }), ); const failureRes = createResponseRecorder(); await failureApi.routes.get('POST /user-memory/v1/sync')( createRequest({ body: { sessionId: 'session-2' } }), failureRes, ); assert.equal(failureRes.statusCode, 500); assert.deepEqual(failureRes.body, { message: '刷新长期记忆失败' }); }); test('items preserves query forwarding, empty fallback, and read errors', async () => { let received = null; const api = createRouterRecorder(); attachPortalUserMemoryRoutes( api, createDependencies({ getMemoryV2: () => ({ async listMemories(input) { received = input; return [{ id: 'memory-1' }]; }, }), }), ); const res = createResponseRecorder(); await api.routes.get('GET /user-memory/v1/items')( createRequest({ query: { status: 'forgotten', limit: '10', offset: '2' } }), res, ); assert.deepEqual(received, { userId: 'user-1', status: 'forgotten', limit: '10', offset: '2', }); assert.deepEqual(res.body, { ok: true, items: [{ id: 'memory-1' }] }); const fallbackApi = createRouterRecorder(); attachPortalUserMemoryRoutes( fallbackApi, createDependencies({ getMemoryV2: () => ({}) }), ); const fallbackRes = createResponseRecorder(); await fallbackApi.routes.get('GET /user-memory/v1/items')( createRequest(), fallbackRes, ); assert.deepEqual(fallbackRes.body, { ok: true, items: [] }); const failureApi = createRouterRecorder(); attachPortalUserMemoryRoutes( failureApi, createDependencies({ getMemoryV2: () => ({ async listMemories() { throw new Error('read failed'); }, }), }), ); const failureRes = createResponseRecorder(); await failureApi.routes.get('GET /user-memory/v1/items')( createRequest(), failureRes, ); assert.equal(failureRes.statusCode, 500); assert.deepEqual(failureRes.body, { message: 'read failed' }); }); test('delete preserves forwarding, unavailable fallback, success, and errors', async () => { let received = null; const skippedApi = createRouterRecorder(); attachPortalUserMemoryRoutes( skippedApi, createDependencies({ getMemoryV2: () => ({}) }), ); const skippedRes = createResponseRecorder(); await skippedApi.routes.get('DELETE /user-memory/v1/items/:memoryId')( createRequest({ params: { memoryId: 'memory-1' } }), skippedRes, ); assert.equal(skippedRes.statusCode, 409); assert.deepEqual(skippedRes.body, { ok: false, skipped: true, reason: 'unavailable', }); const successApi = createRouterRecorder(); attachPortalUserMemoryRoutes( successApi, createDependencies({ getMemoryV2: () => ({ async forgetMemory(input) { received = input; return { ok: true }; }, }), }), ); const successRes = createResponseRecorder(); await successApi.routes.get('DELETE /user-memory/v1/items/:memoryId')( createRequest({ params: { memoryId: 'memory-2' } }), successRes, ); assert.deepEqual(received, { userId: 'user-1', memoryId: 'memory-2' }); assert.deepEqual(successRes.body, { ok: true }); const failureApi = createRouterRecorder(); attachPortalUserMemoryRoutes( failureApi, createDependencies({ getMemoryV2: () => ({ async forgetMemory() { throw new Error('forget failed'); }, }), }), ); const failureRes = createResponseRecorder(); await failureApi.routes.get('DELETE /user-memory/v1/items/:memoryId')( createRequest({ params: { memoryId: 'memory-3' } }), failureRes, ); assert.equal(failureRes.statusCode, 500); assert.deepEqual(failureRes.body, { message: 'forget failed' }); });