422 lines
12 KiB
JavaScript
422 lines
12 KiB
JavaScript
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' });
|
|
});
|