Files
memind/mindspace-chat-save-service.test.mjs
2026-07-27 15:34:35 +08:00

352 lines
9.6 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
createMindSpaceChatSaveService,
mindspaceChatSaveServiceInternals,
} from './mindspace-chat-save-service.mjs';
const USER_ID = '11111111-1111-4111-8111-111111111111';
test('chat save authority creates localized shared HTML and its public URL inside MindSpace', async () => {
const calls = [];
const service = createMindSpaceChatSaveService({
pool: { query: async () => [[]] },
h5Root: '/srv/h5',
storageRoot: '/srv/storage',
resolveMindSpaceUserPublishDirFn: () =>
'/srv/h5/MindSpace/user-1',
async inlinePrivateAssetsInHtmlFn(
pool,
storageRoot,
userId,
html,
) {
calls.push({
kind: 'inline',
pool,
storageRoot,
userId,
html,
});
return { html: '<html>localized</html>' };
},
rewriteWorkspacePublicAssetReferencesFn(
html,
relativePath,
) {
calls.push({
kind: 'rewrite',
html,
relativePath,
});
return '<html>shared</html>';
},
async mkdirFn(directory, options) {
calls.push({ kind: 'mkdir', directory, options });
},
async writeFileFn(...args) {
calls.push({ kind: 'write', args });
},
randomUUIDFn: () =>
'12345678-1234-4123-8123-123456789abc',
buildMindSpacePublicUrlForUserFn(input) {
calls.push({ kind: 'url', input });
return 'https://example.com/MindSpace/user-1/public/shared/report-12345678.html';
},
});
const result = await service.createSharedHtml({
userId: 'user-1',
html: '<html>private</html>',
sourceFilename: '../unsafe report.html',
});
assert.equal(
result.relativePath,
'public/shared/unsafe-report-12345678.html',
);
assert.equal(
result.publicUrl,
'https://example.com/MindSpace/user-1/public/shared/report-12345678.html',
);
assert.equal(calls[0].kind, 'inline');
assert.equal(calls[1].kind, 'rewrite');
assert.equal(calls[2].kind, 'mkdir');
assert.equal(calls[3].kind, 'write');
assert.equal(
calls[3].args[0],
'/srv/h5/MindSpace/user-1/public/shared/unsafe-report-12345678.html',
);
assert.equal(calls[3].args[1], '<html>shared</html>');
assert.equal(calls[4].kind, 'url');
});
test('chat save authority validates and reads workspace HTML inside MindSpace', async () => {
const calls = [];
const service = createMindSpaceChatSaveService({
pool: { query: async () => [[]] },
h5Root: '/srv/h5',
storageRoot: '/srv/storage',
resolvePublishHtmlAbsolutePathFn(
h5Root,
userId,
relativePath,
) {
calls.push({
kind: 'resolve',
h5Root,
userId,
relativePath,
});
return '/srv/h5/MindSpace/user-1/public/page.html';
},
async readFileFn(...args) {
calls.push({ kind: 'read', args });
return '<html><title>Page</title></html>';
},
});
const result = await service.readWorkspaceHtml({
userId: 'user-1',
relativePath: 'public/page.html',
});
assert.equal(
result.html,
'<html><title>Page</title></html>',
);
assert.equal(result.relativePath, 'public/page.html');
assert.equal(calls[0].kind, 'resolve');
assert.equal(calls[1].kind, 'read');
});
test('chat save authority repairs, materializes, and writes workspace HTML inside MindSpace', async () => {
const h5Root = await fs.mkdtemp(
path.join(os.tmpdir(), 'mindspace-chat-save-authority-'),
);
const storageRoot = path.join(h5Root, 'data', 'mindspace');
const storageKey = 'users/u1/assets/asset-good/source.png';
const htmlPath = path.join(
h5Root,
'MindSpace',
USER_ID,
'public',
'report.html',
);
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
await fs.mkdir(
path.dirname(path.join(storageRoot, storageKey)),
{ recursive: true },
);
await fs.writeFile(htmlPath, '<html>old</html>', 'utf8');
await fs.writeFile(
path.join(storageRoot, storageKey),
Buffer.from('png payload'),
);
const pool = {
async query(sql) {
if (sql.includes('SELECT id FROM h5_assets')) {
return [[{ id: 'asset-good' }]];
}
if (sql.includes('JOIN h5_asset_versions')) {
return [[{
id: 'asset-good',
mime_type: 'image/png',
original_filename: 'source.png',
storage_key: storageKey,
}]];
}
throw new Error(`Unexpected SQL: ${sql}`);
},
};
let writeCount = 0;
const service = createMindSpaceChatSaveService({
pool,
h5Root,
storageRoot,
writeFileFn: async (...args) => {
writeCount += 1;
return fs.writeFile(...args);
},
});
const sourceContent =
`/api/mindspace/v1/assets/asset-good/download`;
const html =
'<html><img src="/api/mindspace/v1/assets/asset-missing/download"></html>';
const first = await service.materializeWorkspaceHtml({
userId: USER_ID,
sourceContent,
html,
relativePath: 'public/report.html',
});
assert.equal(first.changed, true);
assert.equal(first.repairedAssetReferences, true);
assert.equal(first.materializedAssetCount, 1);
assert.equal(first.relativePath, 'public/report.html');
assert.match(first.html, /\.tmp-images\/asset-good\.png/);
assert.doesNotMatch(first.html, /\/api\/mindspace\/v1\/assets\//);
assert.equal(await fs.readFile(htmlPath, 'utf8'), first.html);
assert.equal(
(
await fs.readFile(
path.join(
h5Root,
'MindSpace',
USER_ID,
'public',
'.tmp-images',
'asset-good.png',
),
)
).toString(),
'png payload',
);
const second = await service.materializeWorkspaceHtml({
userId: USER_ID,
sourceContent,
html: first.html,
relativePath: 'public/report.html',
});
assert.equal(second.changed, false);
assert.equal(writeCount, 1);
});
test('chat save authority renders preview thumbnail svg inside MindSpace', async () => {
const h5Root = await fs.mkdtemp(
path.join(os.tmpdir(), 'mindspace-chat-save-thumb-'),
);
const storageRoot = path.join(h5Root, 'data', 'mindspace');
const htmlPath = path.join(
h5Root,
'MindSpace',
USER_ID,
'public',
'report.html',
);
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
await fs.writeFile(htmlPath, '<html><title>Report</title></html>', 'utf8');
const pool = { query: async () => [[]] };
const service = createMindSpaceChatSaveService({
pool,
h5Root,
storageRoot,
});
const rendered = await service.renderPreviewThumbnailSvg({
userId: USER_ID,
relativePath: 'public/report.html',
html: '<html><title>Report</title></html>',
title: 'Report',
subtitle: 'Summary',
force: true,
});
assert.match(rendered.svg, /<svg/i);
assert.equal(rendered.contentType, 'image/svg+xml; charset=utf-8');
assert.equal(rendered.relativePath, 'public/report.html');
});
test('chat save authority ensures preview thumbnail sidecars', async () => {
const h5Root = await fs.mkdtemp(
path.join(os.tmpdir(), 'mindspace-chat-save-ensure-'),
);
const storageRoot = path.join(h5Root, 'data', 'mindspace');
const htmlPath = path.join(
h5Root,
'MindSpace',
USER_ID,
'public',
'report.html',
);
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
await fs.writeFile(htmlPath, '<html><title>Report</title></html>', 'utf8');
const service = createMindSpaceChatSaveService({
pool: { query: async () => [[]] },
h5Root,
storageRoot,
});
const result = await service.ensurePreviewThumbnail({
userId: USER_ID,
relativePath: 'public/report.html',
html: '<html><title>Report</title></html>',
title: 'Report',
subtitle: 'Summary',
force: true,
});
assert.equal(result.ready, true);
assert.equal(result.relativePath, 'public/report.html');
await fs.access(
path.join(
h5Root,
'MindSpace',
USER_ID,
'public',
'report.thumbnail.svg',
),
);
});
test('chat save authority rejects paths outside the public HTML zone', async () => {
const service = createMindSpaceChatSaveService({
pool: { query: async () => [[]] },
h5Root: '/tmp/h5',
storageRoot: '/tmp/storage',
});
await assert.rejects(
() =>
service.materializeWorkspaceHtml({
userId: USER_ID,
html: '<html>unsafe</html>',
relativePath: '../private/report.html',
}),
(error) => error?.code === 'invalid_page_path',
);
assert.throws(
() =>
mindspaceChatSaveServiceInternals
.normalizePublicHtmlRelativePath('oa/report.html'),
(error) => error?.code === 'invalid_page_path',
);
});
test('chat save authority preserves best-effort repair behavior', async () => {
let writeCalled = false;
const service = createMindSpaceChatSaveService({
pool: { query: async () => [[]] },
h5Root: '/tmp/h5',
storageRoot: '/tmp/storage',
repairMissingHtmlAssetReferencesFn: async () => {
throw new Error('repair unavailable');
},
materializePrivateAssetsInWorkspaceHtmlFn: async () => {
throw new Error('storage unavailable');
},
writeFileFn: async () => {
writeCalled = true;
},
});
const html = '<html><body>unchanged</body></html>';
const result = await service.materializeWorkspaceHtml({
userId: USER_ID,
sourceContent: 'source',
html,
relativePath: 'report.html',
});
assert.equal(result.html, html);
assert.equal(result.relativePath, 'public/report.html');
assert.equal(result.changed, false);
assert.equal(writeCalled, false);
});