Files
memind/server/portal-session-routes.test.mjs
T
2026-07-25 09:46:10 +08:00

722 lines
19 KiB
JavaScript

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',
]);
});