feat(api): 完善家谱系统API客户端契约
- 实现家谱管理相关方法,包括创建、详情、概览、我的家谱和选项查询 - 添加家谱加入申请功能,支持申请、审核、取消和待审核列表操作 - 集成通知详情获取方法和通知ID安全验证机制 - 完善功德记录、谱文、相册、视频、祭祀活动的完整CRUD操作契约 - 实现家谱成员管理功能,包含成员列表、更新、移除和转让所有者操作 - 优化路径ID验证逻辑,拒绝不安全的数值ID并提供明确错误提示 - 更新测试用例以验证所有新增API方法的路径和请求体白名单机制
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const modulePath = path.join(__dirname, '..', 'public', 'js', 'article-pages.js');
|
||||
const ArticlePages = fs.existsSync(modulePath) ? require(modulePath) : {};
|
||||
|
||||
function requireFunction(name) {
|
||||
assert.equal(typeof ArticlePages[name], 'function', `缺少 ArticlePages.${name}`);
|
||||
return ArticlePages[name];
|
||||
}
|
||||
|
||||
test('谱文页只从 URL 读取安全的真实家谱和谱文编号', () => {
|
||||
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
|
||||
const getCurrentArticleId = requireFunction('getCurrentArticleId');
|
||||
|
||||
assert.equal(
|
||||
getCurrentGenealogyId('?genealogyId=2060000000000000001&articleId=2060000000000000002'),
|
||||
'2060000000000000001'
|
||||
);
|
||||
assert.equal(
|
||||
getCurrentArticleId('?genealogyId=2060000000000000001&articleId=2060000000000000002'),
|
||||
'2060000000000000002'
|
||||
);
|
||||
assert.equal(getCurrentGenealogyId('?genealogyId=unsafe'), '');
|
||||
assert.equal(getCurrentArticleId('?articleId=unsafe'), '');
|
||||
});
|
||||
|
||||
test('谱文写入只构造 ArticleBody 且固定为可重读正常状态', () => {
|
||||
const buildArticleBody = requireFunction('buildArticleBody');
|
||||
|
||||
assert.deepEqual(buildArticleBody({
|
||||
articleTitle: ' 家族源流 ',
|
||||
articleSummary: ' 先祖迁徙记录 ',
|
||||
coverOssId: '2060000000000000003',
|
||||
articleContent: ' <p>正文</p> ',
|
||||
authorName: ' 宗亲 ',
|
||||
sortOrder: '2',
|
||||
status: '1',
|
||||
categoryId: '2060000000000000004',
|
||||
articleId: 'must-not-send'
|
||||
}), {
|
||||
articleTitle: '家族源流',
|
||||
articleSummary: '先祖迁徙记录',
|
||||
coverOssId: '2060000000000000003',
|
||||
articleContent: '<p>正文</p>',
|
||||
authorName: '宗亲',
|
||||
sortOrder: 2,
|
||||
status: '0'
|
||||
});
|
||||
});
|
||||
|
||||
test('谱文校验必填、上传派生 ID、排序和可重读状态', () => {
|
||||
const buildArticleBody = requireFunction('buildArticleBody');
|
||||
const validateArticleBody = requireFunction('validateArticleBody');
|
||||
|
||||
assert.equal(validateArticleBody({ articleTitle: '', articleContent: '正文', status: '0' }), '请填写谱文标题');
|
||||
assert.equal(validateArticleBody({ articleTitle: '标题', articleContent: '', status: '0' }), '请填写谱文正文');
|
||||
assert.equal(
|
||||
validateArticleBody(buildArticleBody({
|
||||
articleTitle: '标题',
|
||||
articleContent: '正文',
|
||||
coverOssId: Number.MAX_SAFE_INTEGER + 1
|
||||
})),
|
||||
'封面文件编号无效,请重新选择文件'
|
||||
);
|
||||
assert.equal(
|
||||
validateArticleBody(buildArticleBody({
|
||||
articleTitle: '标题',
|
||||
articleContent: '正文',
|
||||
sortOrder: '1.5'
|
||||
})),
|
||||
'排序值必须是安全整数'
|
||||
);
|
||||
assert.equal(validateArticleBody({ articleTitle: '标题', articleContent: '正文', status: '1' }), '当前 PC 无法重新读取停用谱文,暂不开放停用');
|
||||
assert.equal(validateArticleBody({ articleTitle: '标题', articleContent: '正文', status: '0' }), '');
|
||||
});
|
||||
|
||||
test('谱文响应使用完整 ArticleVo 并拒绝不安全长 ID', () => {
|
||||
const normalizeArticle = requireFunction('normalizeArticle');
|
||||
const article = normalizeArticle({
|
||||
articleId: '2060000000000000001',
|
||||
genealogyId: '2060000000000000002',
|
||||
genealogyNo: 'G20260729001',
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
categoryId: null,
|
||||
categoryName: '',
|
||||
categoryCode: '',
|
||||
articleTitle: '家族源流',
|
||||
articleSummary: '先祖迁徙记录',
|
||||
coverOssId: '2060000000000000003',
|
||||
articleContent: '<p>正文</p>',
|
||||
authorName: '宗亲',
|
||||
publishTime: '2026-07-29 10:00:00',
|
||||
viewCount: 3,
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: '年度谱文'
|
||||
});
|
||||
|
||||
assert.equal(article.articleId, '2060000000000000001');
|
||||
assert.equal(article.coverOssId, '2060000000000000003');
|
||||
assert.equal(article.viewCount, 3);
|
||||
assert.equal(article.articleTitle, '家族源流');
|
||||
assert.equal(normalizeArticle({
|
||||
articleId: Number('2060000000000000001'),
|
||||
genealogyId: '2',
|
||||
articleTitle: '标题',
|
||||
articleContent: '正文',
|
||||
status: '0'
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('谱文列表只接受直接数组且任一非法元素使整批失败', () => {
|
||||
const normalizeArticles = requireFunction('normalizeArticles');
|
||||
const valid = {
|
||||
articleId: '1',
|
||||
genealogyId: '2',
|
||||
articleTitle: '标题',
|
||||
articleContent: '正文',
|
||||
status: '0'
|
||||
};
|
||||
|
||||
assert.equal(normalizeArticles([valid]).length, 1);
|
||||
assert.deepEqual(normalizeArticles({ rows: [valid] }), []);
|
||||
assert.deepEqual(normalizeArticles([valid, {}]), []);
|
||||
});
|
||||
|
||||
test('谱文详情转义正文并隐藏内部 ID 和 OSS ID', () => {
|
||||
const normalizeArticle = requireFunction('normalizeArticle');
|
||||
const renderArticleDetail = requireFunction('renderArticleDetail');
|
||||
const html = renderArticleDetail(normalizeArticle({
|
||||
articleId: '1',
|
||||
genealogyId: '2',
|
||||
categoryId: '3',
|
||||
coverOssId: '4',
|
||||
articleTitle: '<img src=x onerror=alert(1)>家史',
|
||||
articleSummary: '摘要',
|
||||
articleContent: '<script>alert(1)</script>正文',
|
||||
authorName: '宗亲',
|
||||
publishTime: '2026-07-29 10:00:00',
|
||||
viewCount: 5,
|
||||
status: '0'
|
||||
}));
|
||||
|
||||
assert.doesNotMatch(html, /<script|<img/);
|
||||
assert.match(html, /<img|<script/);
|
||||
assert.match(html, /家史|正文|宗亲|5/);
|
||||
assert.doesNotMatch(html, />1<|>2<|>3<|>4</);
|
||||
});
|
||||
|
||||
test('谱文写后重读必须返回同一条稳定记录', () => {
|
||||
const matchesSavedArticle = requireFunction('matchesSavedArticle');
|
||||
const detail = {
|
||||
articleId: '2060000000000000001',
|
||||
genealogyId: '2060000000000000002',
|
||||
articleTitle: '标题',
|
||||
articleContent: '正文',
|
||||
status: '0'
|
||||
};
|
||||
|
||||
assert.equal(matchesSavedArticle(detail, '2060000000000000001'), true);
|
||||
assert.equal(matchesSavedArticle(detail, '2060000000000000009'), false);
|
||||
assert.equal(matchesSavedArticle({}, '2060000000000000001'), false);
|
||||
});
|
||||
|
||||
test('谱文页面开放 PC CRUD 且不提供手填分类、业务 ID 或停用状态', () => {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-article.html'), 'utf8');
|
||||
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-article-edit.html'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(listPage, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(listPage, /data-article-list/);
|
||||
assert.match(listPage, /data-article-create-link[^>]+hidden|hidden[^>]+data-article-create-link/);
|
||||
assert.match(listPage, /data-article-detail/);
|
||||
assert.match(listPage, /public\/js\/article-pages\.js/);
|
||||
assert.doesNotMatch(editPage, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(editPage, /name="articleTitle"/);
|
||||
assert.match(editPage, /name="articleSummary"/);
|
||||
assert.match(editPage, /name="articleContent"/);
|
||||
assert.match(editPage, /name="authorName"/);
|
||||
assert.match(editPage, /name="coverOssId" type="hidden"/);
|
||||
assert.match(editPage, /name="status" type="hidden" value="0"/);
|
||||
assert.match(editPage, /public\/js\/article-pages\.js/);
|
||||
assert.doesNotMatch(editPage, /name="articleId"|name="categoryId"|type="text"[^>]+coverOssId/);
|
||||
});
|
||||
Reference in New Issue
Block a user