421e204711
Memind CI / Test, build, and release guards (push) Successful in 3m28s
Ship the M成果 page with list/delete UX, fix deletePage for incomplete page records, route /space/achievements, and add M成果 under the WeChat M空间 menu. Co-authored-by: Cursor <cursoragent@cursor.com>
404 lines
14 KiB
JavaScript
404 lines
14 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 { createPageService, pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
|
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
|
|
|
test('parseJsonColumn accepts mysql json objects and strings', () => {
|
|
const objectValue = { relative_path: 'public/demo.html', auto_synced: true };
|
|
assert.deepEqual(pageInternals.parseJsonColumn(objectValue), objectValue);
|
|
assert.deepEqual(
|
|
pageInternals.parseJsonColumn(JSON.stringify(objectValue)),
|
|
objectValue,
|
|
);
|
|
assert.deepEqual(pageInternals.parseJsonColumn(null, { ok: true }), { ok: true });
|
|
});
|
|
|
|
test('normalizePageInput trims values and constrains templates', () => {
|
|
const result = pageInternals.normalizePageInput({
|
|
title: ' 页面标题 ',
|
|
content: ' 页面正文 ',
|
|
templateId: 'unknown-template',
|
|
});
|
|
|
|
assert.equal(result.title, '页面标题');
|
|
assert.equal(result.content, '页面正文');
|
|
assert.equal(result.templateId, 'editorial');
|
|
assert.equal(result.contentBytes, Buffer.byteLength('页面正文'));
|
|
});
|
|
|
|
test('normalizePageInput rejects empty and oversized page content', () => {
|
|
assert.throws(
|
|
() => pageInternals.normalizePageInput({ title: '', content: '正文' }),
|
|
{ code: 'invalid_page_input' },
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
pageInternals.normalizePageInput({
|
|
title: '页面',
|
|
content: '文'.repeat(400_000),
|
|
}),
|
|
{ code: 'page_content_too_large' },
|
|
);
|
|
});
|
|
|
|
test('normalizePageInput maps html content to static-html template', () => {
|
|
const result = pageInternals.normalizePageInput({
|
|
title: '页面',
|
|
content: '<!doctype html><html><body>Hi</body></html>',
|
|
contentFormat: 'html',
|
|
});
|
|
assert.equal(result.contentFormat, 'html');
|
|
assert.equal(result.templateId, 'static-html');
|
|
assert.equal(result.pageType, 'html');
|
|
});
|
|
|
|
test('renderHtmlPreview injects restrictive CSP', () => {
|
|
const html = pageInternals.renderHtmlPreview(
|
|
'<!doctype html><html><head></head><body><h1>Hi</h1></body></html>',
|
|
);
|
|
assert.match(html, /Content-Security-Policy/);
|
|
assert.match(html, /script-src 'none'/);
|
|
});
|
|
|
|
test('renderPublicationHtml serves html pages without escaping markup', () => {
|
|
const html = pageInternals.renderPublicationHtml({
|
|
page_type: 'html',
|
|
title: '麻婆豆腐',
|
|
summary: '川菜',
|
|
template_id: 'static-html',
|
|
version_no: 1,
|
|
content: '<!doctype html><html><head><title>麻婆豆腐</title></head><body><h1>麻婆豆腐</h1></body></html>',
|
|
});
|
|
assert.match(html, /<h1>麻婆豆腐<\/h1>/);
|
|
assert.doesNotMatch(html, /<h1>/);
|
|
assert.doesNotMatch(html, /MINDSPACE/);
|
|
});
|
|
|
|
test('normalizeListPageFilters clamps limit and offset', () => {
|
|
assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 999, offset: -5 }), {
|
|
limit: 100,
|
|
offset: 0,
|
|
});
|
|
assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 15, offset: 30 }), {
|
|
limit: 15,
|
|
offset: 30,
|
|
});
|
|
});
|
|
|
|
test('pageResponse includes publication metrics when publish row is present', () => {
|
|
const response = pageInternals.pageResponse(
|
|
{
|
|
id: 'page-1',
|
|
user_id: 'user-1',
|
|
category_id: 'cat-1',
|
|
category_code: 'public',
|
|
source_session_id: null,
|
|
source_message_id: null,
|
|
source_asset_id: null,
|
|
title: '成果页面',
|
|
summary: '摘要',
|
|
page_type: 'html',
|
|
template_id: 'static-html',
|
|
status: 'published',
|
|
visibility: 'public',
|
|
current_version_id: 'ver-1',
|
|
version_no: 3,
|
|
pub_access_mode: 'public',
|
|
pub_public_url: 'https://m.tkmind.cn/u/john/pages/demo',
|
|
pub_id: 'pub-1',
|
|
pub_status: 'online',
|
|
pub_view_count: 128,
|
|
pub_published_at: 1_700_000_000_000,
|
|
created_at: 1_699_000_000_000,
|
|
updated_at: 1_700_100_000_000,
|
|
},
|
|
{ includeContent: false },
|
|
);
|
|
|
|
assert.equal(response.publicationId, 'pub-1');
|
|
assert.equal(response.publicationStatus, 'online');
|
|
assert.equal(response.viewCount, 128);
|
|
assert.equal(response.clickCount, null);
|
|
assert.equal(response.statsSource, 'publication');
|
|
assert.equal(response.publishedAt, 1_700_000_000_000);
|
|
assert.equal(response.publicationUrl, 'https://m.tkmind.cn/u/john/pages/demo');
|
|
});
|
|
|
|
test('preview rendering escapes active HTML and emits a restrictive CSP', () => {
|
|
const html = pageInternals.renderPreviewHtml({
|
|
title: '<script>alert(1)</script>',
|
|
summary: '<img src=x onerror=alert(1)>',
|
|
content: '# 标题\n\n<script>alert(2)</script>',
|
|
templateId: 'report',
|
|
versionNo: 2,
|
|
});
|
|
|
|
assert.match(html, /Content-Security-Policy/);
|
|
assert.match(html, /default-src 'none'/);
|
|
assert.doesNotMatch(html, /<script>alert/);
|
|
assert.match(html, /<script>alert/);
|
|
assert.match(html, /DRAFT · V2/);
|
|
});
|
|
|
|
test('previewContentSecurityPolicy allows external assets for html pages', () => {
|
|
const htmlCsp = pageInternals.previewContentSecurityPolicy('html');
|
|
assert.match(htmlCsp, /style-src 'unsafe-inline' https:/);
|
|
assert.match(htmlCsp, /img-src data: https:/);
|
|
|
|
const markdownCsp = pageInternals.previewContentSecurityPolicy('markdown');
|
|
assert.match(markdownCsp, /style-src 'unsafe-inline'/);
|
|
assert.doesNotMatch(markdownCsp, /https:/);
|
|
});
|
|
|
|
test('buildPageDeleteSummary lists cascade delete impact', () => {
|
|
const lines = pageInternals.buildPageDeleteSummary({
|
|
page: { title: '麻婆豆腐', versionCount: 2 },
|
|
onlinePublication: { publicUrl: '/u/john/pages/mapo' },
|
|
offlinePublicationCount: 1,
|
|
plazaPosts: [{ id: 'post-1', title: '广场帖', status: 'published', publicationId: 'pub-1' }],
|
|
linkedAssets: [{ id: 'a1' }, { id: 'a2' }],
|
|
linkedAssetBytes: 2048,
|
|
linkedAgentJobCount: 1,
|
|
preservesSourceAsset: true,
|
|
});
|
|
assert.match(lines[0], /麻婆豆腐/);
|
|
assert.ok(lines.some((line) => line.includes('在线公开链接')));
|
|
assert.ok(lines.some((line) => line.includes('广场')));
|
|
assert.ok(lines.some((line) => line.includes('原始上传资料不会被删除')));
|
|
});
|
|
|
|
test('normalizeWorkspaceRelativePath unifies bare html filenames under public/', () => {
|
|
assert.equal(normalizeWorkspaceRelativePath('ai-robot-report.html'), 'public/ai-robot-report.html');
|
|
assert.equal(normalizeWorkspaceRelativePath('public/ai-robot-report.html'), 'public/ai-robot-report.html');
|
|
assert.equal(normalizeWorkspaceRelativePath('/public/demo.html'), 'public/demo.html');
|
|
});
|
|
|
|
test('registerPageArtifactForConversation records page artifact and refreshes manifest', async () => {
|
|
const calls = [];
|
|
const registry = {
|
|
async ensurePackage(input) {
|
|
calls.push(['ensurePackage', input]);
|
|
return { id: 'cp_session-1' };
|
|
},
|
|
async recordArtifact(input) {
|
|
calls.push(['recordArtifact', input]);
|
|
return input;
|
|
},
|
|
async writeManifestForSession(input) {
|
|
calls.push(['writeManifestForSession', input]);
|
|
return { storageKey: 'users/user-1/conversations/session-1/manifest.json' };
|
|
},
|
|
};
|
|
|
|
const result = await pageInternals.registerPageArtifactForConversation({
|
|
registry,
|
|
userId: 'user-1',
|
|
source: { sessionId: 'session-1', messageId: 'message-1' },
|
|
page: {
|
|
id: 'page-1',
|
|
currentVersionId: 'version-1',
|
|
title: '旅行计划',
|
|
sourceMessageId: 'message-fallback',
|
|
},
|
|
contentAssetId: 'asset-1',
|
|
contentMimeType: 'text/html',
|
|
contentBytes: 128,
|
|
storageKey: 'users/user-1/pages/page-1/versions/v1.html',
|
|
versionNo: 1,
|
|
now: 1000,
|
|
});
|
|
|
|
assert.equal(result.id, 'ca_page-1_version-1');
|
|
assert.deepEqual(calls[0], [
|
|
'ensurePackage',
|
|
{
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
title: '旅行计划',
|
|
now: 1000,
|
|
},
|
|
]);
|
|
assert.equal(calls[1][1].artifactKind, 'page');
|
|
assert.equal(calls[1][1].messageId, 'message-1');
|
|
assert.equal(calls[1][1].storageKey, 'users/user-1/pages/page-1/versions/v1.html');
|
|
assert.equal(calls[1][1].canonicalUrl, '/api/mindspace/v1/pages/page-1/preview');
|
|
assert.deepEqual(calls[2], [
|
|
'writeManifestForSession',
|
|
{ userId: 'user-1', sessionId: 'session-1' },
|
|
]);
|
|
});
|
|
|
|
test('registerPageArtifactForConversation skips missing registry or session safely', async () => {
|
|
assert.equal(
|
|
await pageInternals.registerPageArtifactForConversation({
|
|
registry: null,
|
|
userId: 'user-1',
|
|
source: { sessionId: 'session-1' },
|
|
page: { id: 'page-1' },
|
|
}),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
await pageInternals.registerPageArtifactForConversation({
|
|
registry: { ensurePackage: async () => assert.fail('should not be called') },
|
|
userId: 'user-1',
|
|
source: {},
|
|
page: { id: 'page-1' },
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('renderThumbnail falls back to workspace html when storage asset is missing', async () => {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-thumb-'));
|
|
const storageRoot = path.join(tmp, 'storage');
|
|
const h5Root = tmp;
|
|
const userId = 'user-1';
|
|
const pageId = 'page-1';
|
|
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId);
|
|
const html =
|
|
'<!doctype html><html><head><title>Demo</title></head><body><h1>Hi</h1></body></html>';
|
|
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
|
|
await fs.writeFile(path.join(publishDir, 'public/index.html'), html);
|
|
|
|
const pool = {
|
|
query: async (sql) => {
|
|
if (String(sql).includes('FROM h5_page_records')) {
|
|
return [[{
|
|
title: 'Demo',
|
|
summary: '',
|
|
page_type: 'html',
|
|
version_no: 1,
|
|
storage_key: `users/${userId}/pages/missing/versions/v1.md`,
|
|
source_snapshot_json: { relative_path: 'public/index.html' },
|
|
}]];
|
|
}
|
|
if (String(sql).includes('FROM h5_users')) {
|
|
return [[{ username: 'demo' }]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
|
|
const pageService = createPageService(pool, { storageRoot, h5Root });
|
|
const svg = await pageService.renderThumbnail(userId, pageId);
|
|
assert.match(svg, /<svg/i);
|
|
assert.match(svg, /Demo/);
|
|
});
|
|
|
|
test('getPage falls back to workspace html when storage asset is missing', async () => {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-get-page-'));
|
|
const storageRoot = path.join(tmp, 'storage');
|
|
const h5Root = tmp;
|
|
const userId = 'user-1';
|
|
const pageId = 'page-1';
|
|
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId);
|
|
const html =
|
|
'<!doctype html><html><head><title>Page Demo</title></head><body><h1>Body</h1></body></html>';
|
|
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
|
|
await fs.writeFile(path.join(publishDir, 'public/index.html'), html);
|
|
|
|
const pool = {
|
|
query: async (sql) => {
|
|
if (String(sql).includes('FROM h5_page_records')) {
|
|
return [[{
|
|
id: pageId,
|
|
user_id: userId,
|
|
title: 'Page Demo',
|
|
summary: '',
|
|
page_type: 'html',
|
|
template_id: 'static-html',
|
|
current_version_id: 'version-1',
|
|
status: 'draft',
|
|
visibility: 'private',
|
|
category_code: 'draft',
|
|
version_no: 1,
|
|
storage_key: `users/${userId}/pages/missing/versions/v1.md`,
|
|
source_snapshot_json: { relative_path: 'public/index.html' },
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
}]];
|
|
}
|
|
if (String(sql).includes('FROM h5_users')) {
|
|
return [[{ username: 'demo' }]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
|
|
const pageService = createPageService(pool, { storageRoot, h5Root });
|
|
const page = await pageService.getPage(userId, pageId);
|
|
assert.equal(page.title, 'Page Demo');
|
|
assert.match(page.content, /<h1>Body<\/h1>/);
|
|
});
|
|
|
|
test('deletePage succeeds when page content asset chain is incomplete', async () => {
|
|
const userId = 'user-1';
|
|
const pageId = 'page-broken';
|
|
let deletedPage = false;
|
|
const conn = {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
async release() {},
|
|
query: async (sql) => {
|
|
const normalized = String(sql);
|
|
if (normalized.includes('FOR UPDATE') && normalized.includes('h5_page_records')) {
|
|
return [[{ id: pageId, space_id: 'space-1', title: 'test', source_asset_id: null }]];
|
|
}
|
|
if (normalized.includes('FROM h5_page_versions pv')) {
|
|
return [[]];
|
|
}
|
|
if (normalized.includes('FROM h5_publish_records') && normalized.includes("status = 'online'")) {
|
|
return [[]];
|
|
}
|
|
if (normalized.includes('FROM h5_agent_jobs')) {
|
|
return [[{ job_count: 0 }]];
|
|
}
|
|
if (normalized.includes("SET status = 'deleted'") && normalized.includes('h5_page_records')) {
|
|
deletedPage = true;
|
|
return [{ affectedRows: 1 }];
|
|
}
|
|
if (normalized.includes('UPDATE h5_agent_jobs')) {
|
|
return [{ affectedRows: 0 }];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
const pool = {
|
|
getConnection: async () => conn,
|
|
query: async (sql) => {
|
|
const normalized = String(sql);
|
|
if (
|
|
normalized.includes('FROM h5_page_records p') &&
|
|
normalized.includes('page_type')
|
|
) {
|
|
return [[{
|
|
id: pageId,
|
|
title: 'test',
|
|
page_type: 'markdown',
|
|
space_id: 'space-1',
|
|
source_asset_id: null,
|
|
}]];
|
|
}
|
|
if (normalized.includes('JOIN h5_asset_versions')) {
|
|
return [[]];
|
|
}
|
|
if (normalized.includes('source_snapshot_json')) {
|
|
return [[{ source_snapshot_json: null }]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
|
|
const pageService = createPageService(pool, { storageRoot: '/tmp/unused', h5Root: null });
|
|
const result = await pageService.deletePage(userId, pageId);
|
|
assert.equal(result.deleted, true);
|
|
assert.equal(result.pageId, pageId);
|
|
assert.equal(result.title, 'test');
|
|
assert.equal(deletedPage, true);
|
|
});
|