feat(api): 完善家谱系统API客户端契约

- 实现家谱管理相关方法,包括创建、详情、概览、我的家谱和选项查询
- 添加家谱加入申请功能,支持申请、审核、取消和待审核列表操作
- 集成通知详情获取方法和通知ID安全验证机制
- 完善功德记录、谱文、相册、视频、祭祀活动的完整CRUD操作契约
- 实现家谱成员管理功能,包含成员列表、更新、移除和转让所有者操作
- 优化路径ID验证逻辑,拒绝不安全的数值ID并提供明确错误提示
- 更新测试用例以验证所有新增API方法的路径和请求体白名单机制
This commit is contained in:
fizzleaf
2026-07-29 16:57:27 +08:00
parent 59a72fb22b
commit fb1743aa2a
74 changed files with 13166 additions and 1320 deletions
+195
View File
@@ -0,0 +1,195 @@
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', 'album-pages.js');
const AlbumPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof AlbumPages[name], 'function', `缺少 AlbumPages.${name}`);
return AlbumPages[name];
}
test('相册页面只从 URL 读取安全的家谱、相册和照片编号', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
const getCurrentAlbumId = requireFunction('getCurrentAlbumId');
assert.equal(getCurrentGenealogyId('?genealogyId=2060000000000000001'), '2060000000000000001');
assert.equal(getCurrentAlbumId('?albumId=2060000000000000002'), '2060000000000000002');
assert.equal(getCurrentAlbumId('?albumId=unsafe'), '');
});
test('相册写入只构造 AlbumBody 并固定正常状态', () => {
const buildAlbumBody = requireFunction('buildAlbumBody');
assert.deepEqual(buildAlbumBody({
albumName: ' 祠堂旧影 ',
albumDesc: ' 历史照片 ',
coverOssId: '2060000000000000003',
sortOrder: '2',
status: '1',
albumId: 'must-drop',
photoCount: 99
}), {
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
sortOrder: 2,
status: '0'
});
});
test('照片写入只构造 AlbumPhotoBody 并转换拍摄时间', () => {
const buildAlbumPhotoBody = requireFunction('buildAlbumPhotoBody');
assert.deepEqual(buildAlbumPhotoBody({
ossId: '2060000000000000004',
photoTitle: ' 合影 ',
photoDesc: ' 清明祭祖 ',
photographer: ' 宗亲 ',
shootTime: '2026-07-29T10:30',
sortOrder: '3',
status: '1',
albumId: 'must-drop'
}), {
ossId: '2060000000000000004',
photoTitle: '合影',
photoDesc: '清明祭祖',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 3,
status: '0'
});
});
test('相册和照片校验上传派生 ID、日期、排序和停用状态', () => {
const buildAlbumBody = requireFunction('buildAlbumBody');
const buildAlbumPhotoBody = requireFunction('buildAlbumPhotoBody');
const validateAlbumBody = requireFunction('validateAlbumBody');
const validateAlbumPhotoBody = requireFunction('validateAlbumPhotoBody');
assert.equal(validateAlbumBody({ albumName: '', status: '0' }), '请填写相册名称');
assert.equal(
validateAlbumBody(buildAlbumBody({ albumName: '相册', coverOssId: Number.MAX_SAFE_INTEGER + 1 })),
'封面文件编号无效,请重新选择文件'
);
assert.equal(validateAlbumBody({ albumName: '相册', status: '1' }), '当前 PC 无法重新读取停用相册,暂不开放停用');
assert.equal(validateAlbumPhotoBody({ ossId: '', status: '0' }), '请选择并上传照片');
assert.equal(
validateAlbumPhotoBody(buildAlbumPhotoBody({ ossId: '1', shootTime: '2026-02-30T10:00' })),
'拍摄时间格式无效'
);
assert.equal(
validateAlbumPhotoBody(buildAlbumPhotoBody({ ossId: '1', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
});
test('相册和照片响应使用完整 PC Vo 并拒绝不安全 ID', () => {
const normalizeAlbum = requireFunction('normalizeAlbum');
const normalizeAlbumPhoto = requireFunction('normalizeAlbumPhoto');
const album = normalizeAlbum({
albumId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyName: '叶氏家谱',
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
photoCount: 2,
sortOrder: 1,
status: '0',
remark: '旧影'
});
const photo = normalizeAlbumPhoto({
photoId: '2060000000000000004',
genealogyId: '2060000000000000002',
albumId: '2060000000000000001',
albumName: '祠堂旧影',
ossId: '2060000000000000005',
photoTitle: '合影',
photoDesc: '清明祭祖',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 1,
status: '0',
remark: ''
});
assert.equal(album.albumId, '2060000000000000001');
assert.equal(album.photoCount, 2);
assert.equal(photo.photoId, '2060000000000000004');
assert.equal(photo.ossId, '2060000000000000005');
assert.equal(normalizeAlbum({
albumId: Number('2060000000000000001'),
genealogyId: '2',
albumName: '相册',
status: '0'
}), null);
});
test('相册详情通过列表中的稳定 albumId 重读并匹配', () => {
const findAlbumById = requireFunction('findAlbumById');
const albums = [{
albumId: '1',
genealogyId: '2',
albumName: '旧影',
photoCount: 0,
status: '0'
}];
assert.equal(findAlbumById(albums, '1').albumName, '旧影');
assert.equal(findAlbumById(albums, '9'), null);
assert.equal(findAlbumById({ rows: albums }, '1'), null);
});
test('相册和照片展示转义内容并隐藏 OSS ID 与内部 ID', () => {
const normalizeAlbum = requireFunction('normalizeAlbum');
const normalizeAlbumPhoto = requireFunction('normalizeAlbumPhoto');
const renderAlbumDetail = requireFunction('renderAlbumDetail');
const renderPhotoList = requireFunction('renderPhotoList');
const albumHtml = renderAlbumDetail(normalizeAlbum({
albumId: '1',
genealogyId: '2',
albumName: '<img src=x>旧影',
albumDesc: '<script>alert(1)</script>历史',
coverOssId: '3',
photoCount: 1,
status: '0'
}));
const photoHtml = renderPhotoList([normalizeAlbumPhoto({
photoId: '4',
genealogyId: '2',
albumId: '1',
ossId: '5',
photoTitle: '<img src=x>合影',
photoDesc: '祭祖',
status: '0'
})]);
assert.doesNotMatch(albumHtml + photoHtml, /<script|<img/);
assert.doesNotMatch(albumHtml + photoHtml, />2<|>3<|>4<|>5</);
assert.match(albumHtml + photoHtml, /旧影|历史|合影|祭祖/);
});
test('相册页面拆分列表、编辑和照片详情且不允许手填 OSS ID 或状态', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-album.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-album-edit.html'), 'utf8');
const detailPage = fs.readFileSync(path.join(projectRoot, 'profile-album-detail.html'), 'utf8');
[listPage, editPage, detailPage].forEach((source) => {
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /public\/js\/album-pages\.js/);
});
assert.match(listPage, /data-album-list/);
assert.match(editPage, /name="albumName"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-album-detail/);
assert.match(detailPage, /data-album-photo-list/);
assert.match(detailPage, /name="ossId" type="hidden"/);
assert.match(detailPage, /name="shootTime" type="datetime-local"/);
assert.match(detailPage, /name="status" type="hidden" value="0"/);
assert.doesNotMatch(editPage + detailPage, /type="text"[^>]+(?:coverOssId|ossId)|name="albumId"|name="photoId"/);
});
+426 -20
View File
@@ -29,8 +29,10 @@ test('API client exposes latest document-defined PC operations', () => {
'completeResumableUpload',
'captchaRequirement', 'captchaChallenge', 'captchaVerify', 'captchaChallengeUrl', 'captchaVerifyUrl',
'regionChildren', 'regionPath', 'regionSearch', 'regionDetail',
'genealogyQuota',
'notifications', 'unreadNotificationCount', 'markNotificationRead', 'markAllNotificationsRead',
'genealogiesMine', 'genealogyOptions', 'genealogyDetail', 'genealogyOverview', 'genealogyQuota',
'createGenealogy', 'applyToGenealogy', 'myGenealogyJoinApplies',
'pendingGenealogyJoinApplies', 'auditGenealogyJoinApply', 'cancelGenealogyJoinApply',
'notifications', 'notificationDetail', 'unreadNotificationCount', 'markNotificationRead', 'markAllNotificationsRead',
'feeds', 'feedsPage', 'feedDetail', 'createFeed', 'updateFeed', 'deleteFeed',
'likeFeed', 'unlikeFeed', 'feedComments', 'feedCommentsPage', 'createFeedComment',
'feedCommentReplies', 'feedCommentRepliesPage', 'deleteFeedComment',
@@ -41,9 +43,16 @@ test('API client exposes latest document-defined PC operations', () => {
'createLineageChild', 'createLineageParent', 'createLineageSibling', 'createLineageSpouse',
'growthRecords', 'createGrowthRecord', 'growthRecordDetail', 'updateGrowthRecord', 'deleteGrowthRecord',
'relativeRecords', 'createRelativeRecord', 'relativeRecordDetail', 'updateRelativeRecord', 'deleteRelativeRecord',
'memos', 'createMemo', 'memoDetail', 'updateMemo', 'deleteMemo', 'deleteMeritRecord',
'deleteArticle', 'deleteAlbum', 'deleteAlbumPhoto', 'deleteVideo', 'deleteCeremony', 'deleteCeremonyGift',
'replaceCeremonyInvitees', 'ceremonyInvitations', 'respondCeremonyInvitation', 'myCeremonyInvitations'
'memos', 'createMemo', 'memoDetail', 'updateMemo', 'deleteMemo',
'meritRecords', 'createMeritRecord', 'meritRecordDetail', 'updateMeritRecord', 'deleteMeritRecord',
'articles', 'articleDetail', 'createArticle', 'updateArticle', 'deleteArticle',
'albums', 'createAlbum', 'updateAlbum', 'albumPhotos', 'createAlbumPhoto', 'deleteAlbum', 'deleteAlbumPhoto',
'videos', 'createVideo', 'videoDetail', 'updateVideo', 'deleteVideo',
'ceremonies', 'ceremonyDetail', 'createCeremony', 'updateCeremony',
'ceremonyGifts', 'createCeremonyGift', 'deleteCeremony', 'deleteCeremonyGift',
'replaceCeremonyInvitees', 'ceremonyInvitations', 'respondCeremonyInvitation', 'myCeremonyInvitations',
'genealogyMembers', 'genealogyMemberOptions', 'updateGenealogyMember',
'removeGenealogyMember', 'leaveGenealogy', 'transferGenealogyOwner'
].sort();
assert.deepEqual(Object.keys(client).sort(), allowed);
@@ -72,19 +81,19 @@ test('password login stores access_token from the real AppLoginVo response', asy
deviceType: 'pc',
userType: 'app_user',
profile: {
userId: '2062179707935264769',
userId: '9007199254740993001',
tenantId: '000000',
userNo: 'U2062179707910225920',
phone: '19181970173',
nickName: '叶子',
userNo: 'U9007199254740993001',
phone: '13800000000',
nickName: '测试用户',
realName: '',
avatar: null,
sex: '2',
birthday: null,
email: '',
registerSource: 'h5',
loginIp: '112.45.165.24',
loginDate: '2026-07-28 11:06:09',
loginIp: '127.0.0.1',
loginDate: '2026-01-01 00:00:00',
status: '0',
clientKey: 'web_pc',
deviceType: 'pc'
@@ -497,6 +506,113 @@ test('genealogy quota uses the current PC path without manufacturing a genealogy
await client.genealogyQuota();
});
test('genealogy context methods obtain IDs from the real PC list and detail paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: [] } });
}
}
});
await client.genealogiesMine();
await client.genealogyOptions({ keyword: '汤氏' });
await client.genealogyDetail('9007199254740993002');
await client.genealogyOverview('9007199254740993002');
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params]), [
['get', '/genealogy/pc/genealogies/mine', undefined],
['get', '/genealogy/pc/genealogies/options', { keyword: '汤氏' }],
['get', '/genealogy/pc/genealogies/9007199254740993002', undefined],
['get', '/genealogy/pc/genealogies/9007199254740993002/overview', undefined]
]);
});
test('genealogy lifecycle methods use PC paths and strict request bodies', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
calls.push(config);
return Promise.resolve({ data: { code: 200, data: {} } });
}
}
});
const genealogyId = '2062179707935264769';
const applyId = '2062179707935264770';
await client.createGenealogy({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
ancestralHall: '汤氏宗祠',
originPlace: '四川',
addressDetail: '沿口镇',
coverOssId: '2062179707935264771',
intro: '家谱简介',
visibility: '1',
joinMode: '1',
legacyField: 'must-drop'
});
await client.applyToGenealogy(genealogyId, {
applicantName: '申请人',
phone: '19100000000',
relationDesc: '族亲',
applyReason: '申请加入',
inviterUserId: 'must-drop'
});
await client.myGenealogyJoinApplies();
await client.pendingGenealogyJoinApplies(genealogyId);
await client.auditGenealogyJoinApply(genealogyId, applyId, {
status: '1',
auditRemark: '资料一致',
legacyField: 'must-drop'
});
await client.cancelGenealogyJoinApply(applyId);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['post', '/genealogy/pc/genealogies', {
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
ancestralHall: '汤氏宗祠',
originPlace: '四川',
addressDetail: '沿口镇',
coverOssId: '2062179707935264771',
intro: '家谱简介',
visibility: '1',
joinMode: '1'
}],
['post', '/genealogy/pc/genealogies/2062179707935264769/join-applies', {
applicantName: '申请人',
phone: '19100000000',
relationDesc: '族亲',
applyReason: '申请加入'
}],
['get', '/genealogy/pc/genealogies/join-applies/mine', undefined],
['get', '/genealogy/pc/genealogies/2062179707935264769/join-applies/pending', undefined],
['put', '/genealogy/pc/genealogies/2062179707935264769/join-applies/2062179707935264770/audit', {
status: '1',
auditRemark: '资料一致'
}],
['delete', '/genealogy/pc/genealogies/join-applies/2062179707935264770', undefined]
]);
assert.throws(
() => client.pendingGenealogyJoinApplies(Number.MAX_SAFE_INTEGER + 1),
/家谱编号/
);
assert.throws(
() => client.cancelGenealogyJoinApply(Number.MAX_SAFE_INTEGER + 1),
/申请编号/
);
});
test('notification methods use the current PC list, unread and read paths', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -510,13 +626,15 @@ test('notification methods use the current PC list, unread and read paths', asyn
}
});
await client.notifications({ readStatus: '0' });
await client.notifications({ readStatus: '0', pageNum: 99 });
await client.notificationDetail('2060000000000000001');
await client.unreadNotificationCount();
await client.markNotificationRead('2060000000000000001');
await client.markAllNotificationsRead();
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params, config.data]), [
['get', '/genealogy/pc/notifications', { readStatus: '0' }, undefined],
['get', '/genealogy/pc/notifications/2060000000000000001', undefined, undefined],
['get', '/genealogy/pc/notifications/unread-count', undefined, undefined],
['post', '/genealogy/pc/notifications/2060000000000000001/read', undefined, undefined],
['post', '/genealogy/pc/notifications/read-all', undefined, undefined]
@@ -527,6 +645,24 @@ test('notification methods use the current PC list, unread and read paths', asyn
});
});
test('notification methods reject unsafe numeric path IDs before sending a request', async () => {
let requestCount = 0;
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request() {
requestCount += 1;
return Promise.resolve({ data: { code: 200, data: null } });
}
}
});
assert.throws(() => client.notificationDetail(Number.MAX_SAFE_INTEGER + 1));
assert.throws(() => client.markNotificationRead(Number.MAX_SAFE_INTEGER + 1));
assert.equal(requestCount, 0);
});
test('family feed methods use the documented paths and request bodies', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -785,22 +921,54 @@ test('memo methods use the Apifox PC paths and documented request fields', async
]);
});
test('merit record deletion uses the only documented PC merit operation', async () => {
test('merit record methods use the complete PC CRUD paths and MeritRecordBody', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: {
request(config) {
assert.equal(config.method, 'delete');
assert.equal(config.url, '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002');
assert.equal(config.headers.Authorization, 'Bearer access-token');
assert.equal(config.headers.clientid, 'ced7e5f0498645c6ec642dcf450b036f');
return Promise.resolve({ data: { code: 200, data: null } });
calls.push(config);
return Promise.resolve({ data: { code: 200, data: {} } });
}
}
});
const genealogyId = '2060000000000000001';
const meritId = '2060000000000000002';
const record = {
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与宗祠修缮',
amount: 500.5,
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0'
};
const recordWithExtras = Object.assign({}, record, {
appUserId: 'must-not-send',
mediaOssIds: 'must-not-send'
});
await client.deleteMeritRecord('2060000000000000001', '2060000000000000002');
await client.meritRecords(genealogyId);
await client.createMeritRecord(genealogyId, recordWithExtras);
await client.meritRecordDetail(genealogyId, meritId);
await client.updateMeritRecord(genealogyId, meritId, recordWithExtras);
await client.deleteMeritRecord(genealogyId, meritId);
assert.deepEqual(calls.map((config) => [
config.method,
config.url,
config.data,
config.headers.Authorization,
config.headers.clientid
]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/merit-records', undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['post', '/genealogy/pc/genealogies/2060000000000000001/merit-records', record, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['get', '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002', undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['put', '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002', record, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f'],
['delete', '/genealogy/pc/genealogies/2060000000000000001/merit-records/2060000000000000002', undefined, 'Bearer access-token', 'ced7e5f0498645c6ec642dcf450b036f']
]);
});
test('remaining document-defined deletion operations use PC paths only', async () => {
@@ -829,6 +997,244 @@ test('remaining document-defined deletion operations use PC paths only', async (
]);
});
test('article methods use the complete PC CRUD paths and ArticleBody whitelist', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const articleId = '2060000000000000002';
const input = {
categoryId: '2060000000000000003',
articleTitle: '家族源流',
articleSummary: '摘要',
coverOssId: '2060000000000000004',
articleContent: '<p>正文</p>',
authorName: '宗亲',
sortOrder: 1,
status: '0',
appUserId: 'must-drop'
};
const body = {
categoryId: '2060000000000000003',
articleTitle: '家族源流',
articleSummary: '摘要',
coverOssId: '2060000000000000004',
articleContent: '<p>正文</p>',
authorName: '宗亲',
sortOrder: 1,
status: '0'
};
await client.articles(genealogyId);
await client.createArticle(genealogyId, input);
await client.articleDetail(genealogyId, articleId);
await client.updateArticle(genealogyId, articleId, input);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/articles', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/articles', body],
['get', '/genealogy/pc/genealogies/2060000000000000001/articles/2060000000000000002', undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/articles/2060000000000000002', body]
]);
});
test('album methods use the complete PC album and photo contracts', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const albumId = '2060000000000000002';
const albumInput = {
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
sortOrder: 2,
status: '0',
photoCount: 99
};
const albumBody = {
albumName: '祠堂旧影',
albumDesc: '历史照片',
coverOssId: '2060000000000000003',
sortOrder: 2,
status: '0'
};
const photoInput = {
ossId: '2060000000000000004',
photoTitle: '合影',
photoDesc: '祭祖合影',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 3,
status: '0',
albumId: 'must-drop'
};
const photoBody = {
ossId: '2060000000000000004',
photoTitle: '合影',
photoDesc: '祭祖合影',
photographer: '宗亲',
shootTime: '2026-07-29 10:30:00',
sortOrder: 3,
status: '0'
};
await client.albums(genealogyId);
await client.createAlbum(genealogyId, albumInput);
await client.updateAlbum(genealogyId, albumId, albumInput);
await client.albumPhotos(genealogyId, albumId);
await client.createAlbumPhoto(genealogyId, albumId, photoInput);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/albums', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/albums', albumBody],
['put', '/genealogy/pc/genealogies/2060000000000000001/albums/2060000000000000002', albumBody],
['get', '/genealogy/pc/genealogies/2060000000000000001/albums/2060000000000000002/photos', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/albums/2060000000000000002/photos', photoBody]
]);
});
test('ceremony admin methods use PC activity and gift contracts', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const ceremonyId = '2060000000000000002';
const ceremonyInput = {
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '宗亲祭祖',
ceremonyTime: '2026-04-05 09:00:00',
location: '祠堂',
locationAddress: '宗祠路 1 号',
longitude: 104.1,
latitude: 30.6,
coverOssId: '2060000000000000003',
sortOrder: 1,
status: '0',
giftCount: 10
};
const ceremonyBody = {
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '宗亲祭祖',
ceremonyTime: '2026-04-05 09:00:00',
location: '祠堂',
locationAddress: '宗祠路 1 号',
longitude: 104.1,
latitude: 30.6,
coverOssId: '2060000000000000003',
sortOrder: 1,
status: '0'
};
const giftInput = {
giverName: '宗亲',
giftAmount: 88.5,
giftMessage: '敬献',
status: 'must-drop'
};
const giftBody = { giverName: '宗亲', giftAmount: 88.5, giftMessage: '敬献' };
await client.ceremonies(genealogyId);
await client.createCeremony(genealogyId, ceremonyInput);
await client.ceremonyDetail(genealogyId, ceremonyId);
await client.updateCeremony(genealogyId, ceremonyId, ceremonyInput);
await client.ceremonyGifts(genealogyId, ceremonyId);
await client.createCeremonyGift(genealogyId, ceremonyId, giftInput);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/ceremonies', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/ceremonies', ceremonyBody],
['get', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002', undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002', ceremonyBody],
['get', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002/gifts', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/ceremonies/2060000000000000002/gifts', giftBody]
]);
});
test('genealogy member methods use PC member paths and body whitelists', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const memberId = '2060000000000000002';
const memberInput = {
memberName: '族员甲',
relationName: '侄',
roleType: 'editor',
lineagePersonId: '2060000000000000003',
appUserId: 'must-drop'
};
const memberBody = {
memberName: '族员甲',
relationName: '侄',
roleType: 'editor',
lineagePersonId: '2060000000000000003'
};
await client.genealogyMembers(genealogyId);
await client.genealogyMemberOptions(genealogyId);
await client.updateGenealogyMember(genealogyId, memberId, memberInput);
await client.removeGenealogyMember(genealogyId, memberId);
await client.leaveGenealogy(genealogyId);
await client.transferGenealogyOwner(genealogyId, { targetMemberId: memberId, roleType: 'must-drop' });
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/members', undefined, undefined],
['get', '/genealogy/pc/genealogies/2060000000000000001/members/options', undefined, undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/members/2060000000000000002', undefined, memberBody],
['delete', '/genealogy/pc/genealogies/2060000000000000001/members/2060000000000000002', undefined, undefined],
['delete', '/genealogy/pc/genealogies/2060000000000000001/members/me', undefined, undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/members/owner-transfer', undefined, {
targetMemberId: memberId
}]
]);
});
test('video methods use the complete PC CRUD paths and VideoBody', async () => {
const calls = [];
const client = GenealogyApi.createClient({
baseUrl: 'https://api.example.test',
tokenStore: { getItem() { return 'access-token'; }, setItem() {}, removeItem() {} },
axiosInstance: { request(config) { calls.push(config); return Promise.resolve({ data: { code: 200, data: {} } }); } }
});
const genealogyId = '2060000000000000001';
const videoId = '2060000000000000002';
const body = {
videoTitle: '家族活动记录',
videoDesc: '清明祭祖活动视频',
coverOssId: '2060000000000000003',
videoOssId: '2060000000000000004',
durationSeconds: 180,
sortOrder: 1,
status: '0'
};
await client.videos(genealogyId);
await client.createVideo(genealogyId, body);
await client.videoDetail(genealogyId, videoId);
await client.updateVideo(genealogyId, videoId, body);
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
['get', '/genealogy/pc/genealogies/2060000000000000001/videos', undefined],
['post', '/genealogy/pc/genealogies/2060000000000000001/videos', body],
['get', '/genealogy/pc/genealogies/2060000000000000001/videos/2060000000000000002', undefined],
['put', '/genealogy/pc/genealogies/2060000000000000001/videos/2060000000000000002', body]
]);
});
test('ceremony invitation methods use the current PC invitation contracts', async () => {
const calls = [];
const client = GenealogyApi.createClient({
@@ -839,7 +1245,7 @@ test('ceremony invitation methods use the current PC invitation contracts', asyn
const genealogyId = '2060000000000000001';
const ceremonyId = '2060000000000000002';
await client.replaceCeremonyInvitees(genealogyId, ceremonyId, { inviteeUserIds: ['900000001', '900000002'] });
await client.replaceCeremonyInvitees(genealogyId, ceremonyId, ['900000001', '900000002']);
await client.ceremonyInvitations(genealogyId, ceremonyId);
await client.respondCeremonyInvitation(genealogyId, ceremonyId, { inviteStatus: 'ACCEPTED' });
await client.myCeremonyInvitations();
+188
View File
@@ -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, /&lt;img|&lt;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/);
});
+251
View File
@@ -0,0 +1,251 @@
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', 'ceremony-admin-pages.js');
const CeremonyAdminPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof CeremonyAdminPages[name], 'function', `缺少 CeremonyAdminPages.${name}`);
return CeremonyAdminPages[name];
}
test('祭祀管理页只从上下文读取安全的家谱和活动 ID', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
const getCurrentCeremonyId = requireFunction('getCurrentCeremonyId');
assert.equal(getCurrentGenealogyId('?genealogyId=2060000000000000001'), '2060000000000000001');
assert.equal(getCurrentCeremonyId('?ceremonyId=2060000000000000002'), '2060000000000000002');
assert.equal(getCurrentCeremonyId('?ceremonyId=unsafe'), '');
});
test('活动写入只构造 CeremonyBody 并固定正常状态', () => {
const buildCeremonyBody = requireFunction('buildCeremonyBody');
assert.deepEqual(buildCeremonyBody({
ceremonyType: ' ancestor ',
ceremonyTitle: ' 清明祭祖 ',
ceremonyDesc: ' 缅怀先祖 ',
ceremonyTime: '2026-04-04T09:00',
location: ' 祠堂 ',
locationAddress: ' 四川省成都市示例路1号 ',
longitude: '104.066541',
latitude: '30.572269',
coverOssId: '2060000000000000003',
sortOrder: '1',
status: '1',
ceremonyId: 'must-drop',
giftAmount: 999
}), {
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '缅怀先祖',
ceremonyTime: '2026-04-04 09:00:00',
location: '祠堂',
locationAddress: '四川省成都市示例路1号',
longitude: 104.066541,
latitude: 30.572269,
coverOssId: '2060000000000000003',
sortOrder: 1,
status: '0'
});
});
test('活动校验必填字段、成对坐标、范围、长度和上传派生 ID', () => {
const buildCeremonyBody = requireFunction('buildCeremonyBody');
const validateCeremonyBody = requireFunction('validateCeremonyBody');
assert.equal(validateCeremonyBody({ ceremonyType: '', ceremonyTitle: '', status: '0' }), '请填写活动类型');
assert.equal(validateCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '', status: '0' }), '请填写活动标题');
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', longitude: '104' })),
'经度和纬度必须同时提供'
);
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', longitude: '181', latitude: '30' })),
'经度范围必须是 -180 到 180'
);
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', locationAddress: '地'.repeat(301) })),
'详细地址不能超过 300 个字符'
);
assert.equal(
validateCeremonyBody(buildCeremonyBody({ ceremonyType: 'ancestor', ceremonyTitle: '祭祖', coverOssId: Number.MAX_SAFE_INTEGER + 1 })),
'封面文件编号无效,请重新选择文件'
);
});
test('祭品写入只提交 CeremonyGiftBody 并校验后端非负金额', () => {
const buildCeremonyGiftBody = requireFunction('buildCeremonyGiftBody');
const validateCeremonyGiftBody = requireFunction('validateCeremonyGiftBody');
assert.deepEqual(buildCeremonyGiftBody({
giverName: ' 叶子 ',
giftAmount: '66.66',
giftMessage: ' 缅怀先祖 ',
giverUserId: 'must-drop',
giftTime: 'must-drop'
}), {
giverName: '叶子',
giftAmount: 66.66,
giftMessage: '缅怀先祖'
});
assert.equal(validateCeremonyGiftBody(buildCeremonyGiftBody({ giftAmount: '' })), '请填写礼金金额');
assert.equal(validateCeremonyGiftBody(buildCeremonyGiftBody({ giftAmount: '-0.01' })), '礼金金额不能小于 0');
assert.equal(validateCeremonyGiftBody(buildCeremonyGiftBody({ giftAmount: 'not-number' })), '礼金金额必须是有效数字');
});
test('活动、祭品和邀请响应使用完整 PC VO 并拒绝不安全 ID', () => {
const normalizeCeremony = requireFunction('normalizeCeremony');
const normalizeCeremonyGift = requireFunction('normalizeCeremonyGift');
const normalizeInvitation = requireFunction('normalizeInvitation');
const ceremony = normalizeCeremony({
ceremonyId: '2060000000000000001',
genealogyId: '2060000000000000002',
sponsorUserId: '2060000000000000003',
sponsorNickName: '叶子',
sponsorPhone: '19181970173',
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '缅怀先祖',
ceremonyTime: '2026-04-04 09:00:00',
location: '祠堂',
locationAddress: '成都',
longitude: 104.066541,
latitude: 30.572269,
coverOssId: '2060000000000000004',
giftCount: 2,
giftAmount: 88.88,
sortOrder: 1,
status: '0',
remark: ''
});
const gift = normalizeCeremonyGift({
giftId: '2060000000000000005',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000001',
ceremonyTitle: '清明祭祖',
giverUserId: '2060000000000000006',
giverNickName: '宗亲',
giverPhone: '19100000000',
giverName: '叶先生',
giftAmount: 66.66,
giftMessage: '缅怀先祖',
giftTime: '2026-04-04 10:00:00',
status: '0',
remark: ''
});
const invitation = normalizeInvitation({
invitationId: '2060000000000000007',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000001',
inviteeUserId: '2060000000000000006',
inviteStatus: 'PENDING',
inviteVersion: 1
});
assert.equal(ceremony.ceremonyId, '2060000000000000001');
assert.equal(ceremony.giftCount, 2);
assert.equal(gift.giftId, '2060000000000000005');
assert.equal(invitation.inviteeUserId, '2060000000000000006');
assert.equal(normalizeCeremony({
ceremonyId: Number('2060000000000000001'),
genealogyId: '2',
ceremonyType: 'ancestor',
ceremonyTitle: '祭祖',
status: '0'
}), null);
});
test('管理员邀约名单只使用正常成员中的真实 appUserId 并去重', () => {
const normalizeInviteeOption = requireFunction('normalizeInviteeOption');
const buildInviteesBody = requireFunction('buildInviteesBody');
const normal = normalizeInviteeOption({
memberId: '1',
genealogyId: '2',
appUserId: '2060000000000000001',
appUserNickName: '宗亲',
memberName: '叶先生',
status: '0'
});
assert.equal(normal.appUserId, '2060000000000000001');
assert.equal(normalizeInviteeOption({
memberId: '1',
genealogyId: '2',
appUserId: null,
memberName: '未绑定成员',
status: '0'
}), null);
assert.equal(normalizeInviteeOption({
memberId: '1',
genealogyId: '2',
appUserId: '3',
memberName: '停用成员',
status: '1'
}), null);
assert.deepEqual(buildInviteesBody([
'2060000000000000001',
'2060000000000000001',
'2060000000000000002',
'unsafe'
]), {
inviteeUserIds: ['2060000000000000001', '2060000000000000002']
});
});
test('活动和祭品展示转义内容且不暴露手机号、OSS ID 或内部 ID', () => {
const normalizeCeremony = requireFunction('normalizeCeremony');
const normalizeCeremonyGift = requireFunction('normalizeCeremonyGift');
const renderCeremonyDetail = requireFunction('renderCeremonyDetail');
const renderGiftList = requireFunction('renderGiftList');
const html = renderCeremonyDetail(normalizeCeremony({
ceremonyId: '2060000000000000001',
genealogyId: '2060000000000000002',
sponsorUserId: '2060000000000000003',
sponsorNickName: '<img src=x>叶子',
sponsorPhone: '19181970173',
ceremonyType: 'ancestor',
ceremonyTitle: '<script>alert(1)</script>祭祖',
coverOssId: '2060000000000000004',
giftCount: 1,
giftAmount: 66.66,
status: '0'
})) + renderGiftList([normalizeCeremonyGift({
giftId: '2060000000000000005',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000001',
giverUserId: '2060000000000000006',
giverNickName: '<img src=x>宗亲',
giverPhone: '19100000000',
giverName: '叶先生',
giftAmount: 66.66,
giftMessage: '<script>alert(2)</script>缅怀',
status: '0'
})]);
assert.doesNotMatch(html, /<script|<img/);
assert.doesNotMatch(html, /19181970173|19100000000|206000000000000000[1-6]/);
assert.match(html, /祭祖|缅怀|66\.66/);
});
test('祭祀功能拆分列表、编辑和详情页面且不允许手填 ID', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-ceremony.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-gift-edit.html'), 'utf8');
const detailPage = fs.readFileSync(path.join(projectRoot, 'profile-ceremony-detail.html'), 'utf8');
[listPage, editPage, detailPage].forEach((source) => {
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /public\/js\/ceremony-admin-pages\.js/);
});
assert.match(listPage, /data-ceremony-list/);
assert.match(editPage, /name="ceremonyType"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-ceremony-gift-list/);
assert.match(detailPage, /data-ceremony-invitee-options/);
assert.match(detailPage, /name="giftAmount"/);
assert.doesNotMatch(editPage + detailPage, /name="(?:genealogyId|ceremonyId|giftId|inviteeUserIds)"/);
});
+138
View File
@@ -0,0 +1,138 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const CeremonyPages = require('../public/js/ceremony-pages.js');
test('我的邀请响应保留完整展示字段并保持业务 ID 为字符串', () => {
assert.deepEqual(CeremonyPages.normalizeInvitation({
invitationId: '2060000000000000001',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000003',
inviteeUserId: '2060000000000000004',
inviteStatus: 'PENDING',
inviteVersion: 2,
deliveredTime: '2026-07-29T09:00:00+08:00',
readTime: null,
responseTime: null,
ceremonyTitle: '家族答谢宴',
ceremonyTime: '2026-08-08T18:00:00+08:00',
location: '锦江厅',
locationAddress: '成都市锦江区示例路 8 号',
longitude: 104.0668,
latitude: 30.5728
}), {
invitationId: '2060000000000000001',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000003',
inviteeUserId: '2060000000000000004',
inviteStatus: 'PENDING',
inviteVersion: 2,
deliveredTime: '2026-07-29T09:00:00+08:00',
readTime: '',
responseTime: '',
ceremonyTitle: '家族答谢宴',
ceremonyTime: '2026-08-08T18:00:00+08:00',
location: '锦江厅',
locationAddress: '成都市锦江区示例路 8 号',
longitude: 104.0668,
latitude: 30.5728
});
});
test('邀请响应拒绝缺少稳定 ID、非法状态和不安全数字长 ID', () => {
assert.equal(CeremonyPages.normalizeInvitation({
invitationId: '1',
genealogyId: '2',
inviteeUserId: '4',
inviteStatus: 'PENDING'
}), null);
assert.equal(CeremonyPages.normalizeInvitation({
invitationId: '1',
genealogyId: '2',
ceremonyId: '3',
inviteeUserId: '4',
inviteStatus: 'UNKNOWN'
}), null);
assert.equal(CeremonyPages.normalizeInvitation({
invitationId: Number('2060000000000000001'),
genealogyId: '2',
ceremonyId: '3',
inviteeUserId: '4',
inviteStatus: 'PENDING'
}), null);
});
test('YAML 未声明必返的 inviteeUserId 缺失时仍可用活动上下文响应', () => {
const invitation = CeremonyPages.normalizeInvitation({
invitationId: '1',
genealogyId: '2',
ceremonyId: '3',
inviteStatus: 'PENDING'
});
assert.notEqual(invitation, null);
assert.equal(invitation.inviteeUserId, undefined);
assert.equal(CeremonyPages.canRespondToInvitation(invitation), true);
});
test('响应邀请只允许提交 YAML 定义的 inviteStatus 字段', () => {
assert.deepEqual(CeremonyPages.buildInvitationResponseBody({
inviteStatus: 'ACCEPTED',
invitationId: '2060000000000000001',
inviteVersion: 2
}), {
inviteStatus: 'ACCEPTED'
});
assert.deepEqual(CeremonyPages.buildInvitationResponseBody({ inviteStatus: 'DECLINED' }), {
inviteStatus: 'DECLINED'
});
assert.equal(CeremonyPages.validateInvitationResponseBody({ inviteStatus: 'PENDING' }), '邀请只能选择接受或拒绝');
});
test('只有待响应邀请显示接受和拒绝动作', () => {
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'PENDING' }), true);
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'ACCEPTED' }), false);
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'DECLINED' }), false);
assert.equal(CeremonyPages.canRespondToInvitation({ inviteStatus: 'CANCELED' }), false);
});
test('邀请列表渲染转义内容、展示详情但不直接暴露内部 ID 和坐标', () => {
const invitation = CeremonyPages.normalizeInvitation({
invitationId: '2060000000000000001',
genealogyId: '2060000000000000002',
ceremonyId: '2060000000000000003',
inviteeUserId: '2060000000000000004',
inviteStatus: 'PENDING',
inviteVersion: 1,
ceremonyTitle: '<script>alert(1)</script>',
ceremonyTime: '2026-08-08T18:00:00+08:00',
location: '锦江厅',
locationAddress: '成都市锦江区示例路 8 号',
longitude: 104.0668,
latitude: 30.5728
});
const row = CeremonyPages.renderInvitationRow(invitation);
const detail = CeremonyPages.renderInvitationDetail(invitation);
assert.doesNotMatch(row, /<script>/);
assert.match(row, /接受邀请/);
assert.match(row, /拒绝邀请/);
assert.match(detail, /成都市锦江区示例路 8 号/);
assert.match(detail, /打开地图/);
assert.doesNotMatch(detail, /206000000000000000[1-4]/);
assert.doesNotMatch(detail, />104\.0668</);
assert.doesNotMatch(detail, />30\.5728</);
});
test('贺礼邀请页面只接入我的邀请闭环,不提供活动或献礼猜测表单', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-gift.html'), 'utf8');
assert.doesNotMatch(page, /data-feature-status="pending"/);
assert.match(page, /data-my-invitation-list/);
assert.match(page, /data-my-invitation-detail/);
assert.match(page, /src="public\/js\/ceremony-pages\.js"/);
assert.doesNotMatch(page, /data-ceremony-gift-form/);
assert.doesNotMatch(page, /href="profile-gift-edit\.html"/);
});
+196
View File
@@ -1,7 +1,14 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const FeedPages = require('../public/js/feed-pages.js');
const rootDir = path.resolve(__dirname, '..');
function read(file) {
return fs.readFileSync(path.join(rootDir, file), 'utf8');
}
test('动态表单只构造最新文档定义的 FamilyFeedBody 字段', () => {
assert.deepEqual(
@@ -71,3 +78,192 @@ test('评论响应使用 commentContent,并保留已删除评论的占位状
userDeleted: true
});
});
test('动态响应保留页面闭环所需的完整 FamilyFeedView 字段和字符串 ID', () => {
assert.deepEqual(FeedPages.normalizeFeed({
feedId: 2060000000000000000n,
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
publisherUserId: '2062000000000000000',
publisherNickName: '叶子',
publisherStatus: '0',
feedType: 'text',
feedContent: '今日修谱。',
mediaOssIds: '301,302',
likedByMe: true,
likeCount: 8,
commentCount: 3,
pinned: '1',
pinnedTime: '2026-07-28 10:00:00',
sortOrder: 2,
status: '0',
remark: '族长发布',
createTime: '2026-07-28 09:00:00',
updateTime: '2026-07-28 09:30:00'
}), {
feedId: '2060000000000000000',
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
publisherUserId: '2062000000000000000',
publisherNickName: '叶子',
publisherStatus: '0',
feedType: 'text',
feedContent: '今日修谱。',
mediaOssIds: '301,302',
likedByMe: true,
likeCount: 8,
commentCount: 3,
pinned: '1',
pinnedTime: '2026-07-28 10:00:00',
sortOrder: 2,
status: '0',
remark: '族长发布',
createTime: '2026-07-28 09:00:00',
updateTime: '2026-07-28 09:30:00'
});
});
test('评论响应保留回复、归属、层级和时间字段', () => {
assert.deepEqual(FeedPages.normalizeComment({
commentId: '2060000000000000001',
genealogyId: '2061000000000000000',
feedId: '2060000000000000000',
parentCommentId: '2060000000000000002',
appUserId: '2062000000000000000',
parentAppUserId: '2062000000000000003',
appUserNickName: '叶子',
appUserAvatar: '401',
parentAppUserNickName: '小叶',
commentContent: '收到',
replyCount: 2,
commentLevel: 'reply',
userDeleted: '0',
status: '0',
createTime: '2026-07-28 10:30:00'
}), {
commentId: '2060000000000000001',
genealogyId: '2061000000000000000',
feedId: '2060000000000000000',
parentCommentId: '2060000000000000002',
appUserId: '2062000000000000000',
parentAppUserId: '2062000000000000003',
appUserNickName: '叶子',
appUserAvatar: '401',
parentAppUserNickName: '小叶',
commentContent: '收到',
replyCount: 2,
commentLevel: 'reply',
userDeleted: false,
status: '0',
createTime: '2026-07-28 10:30:00'
});
});
test('动态、评论和回复分页只接受正整数页码并使用固定页面大小', () => {
assert.deepEqual(FeedPages.buildPageQuery('3'), { pageNum: 3, pageSize: 20 });
assert.deepEqual(FeedPages.buildPageQuery('-1'), { pageNum: 1, pageSize: 20 });
assert.deepEqual(FeedPages.buildPageQuery('1.5'), { pageNum: 1, pageSize: 20 });
});
test('动态列表、详情和编辑深链始终同时携带真实家谱和动态 ID', () => {
assert.equal(
FeedPages.buildFeedUrl('detail', '2061000000000000000', '2060000000000000000'),
'profile-feed-detail.html?genealogyId=2061000000000000000&feedId=2060000000000000000'
);
assert.equal(
FeedPages.buildFeedUrl('edit', '2061000000000000000', '2060000000000000000'),
'profile-feed-edit.html?genealogyId=2061000000000000000&feedId=2060000000000000000'
);
assert.equal(
FeedPages.buildFeedUrl('list', '2061000000000000000'),
'profile-feed.html?genealogyId=2061000000000000000'
);
});
test('正常动态保存后进入详情,停用动态保存后返回可读取的列表', () => {
assert.equal(
FeedPages.buildPostSaveUrl('2061000000000000000', '2060000000000000000', '0'),
'profile-feed-detail.html?genealogyId=2061000000000000000&feedId=2060000000000000000'
);
assert.equal(
FeedPages.buildPostSaveUrl('2061000000000000000', '2060000000000000000', '1'),
'profile-feed.html?genealogyId=2061000000000000000'
);
});
test('动态表单省略未填写的可选字段并拒绝契约外状态', () => {
assert.deepEqual(FeedPages.buildFeedBody({
feedContent: '仅发布文字'
}), {
feedType: 'text',
feedContent: '仅发布文字'
});
assert.throws(
() => FeedPages.buildFeedBody({ feedContent: '错误状态', status: 'draft' }),
/状态只能是 0 或 1/
);
});
test('同一动态动作在请求完成前只执行一次', async () => {
let resolveAction;
let calls = 0;
const action = new Promise((resolve) => {
resolveAction = resolve;
});
const first = FeedPages.withActionLock('like:2060', async () => {
calls += 1;
await action;
return 'ok';
});
const second = FeedPages.withActionLock('like:2060', async () => {
calls += 1;
});
assert.equal(first, second);
assert.equal(calls, 1);
resolveAction();
assert.equal(await first, 'ok');
});
test('家谱能力和当前用户决定编辑、管理与本人删除上下文', () => {
assert.deepEqual(
FeedPages.normalizeViewerContext(
{ canEditContent: true, canManage: false },
{ userId: 2062000000000000000n }
),
{
userId: '2062000000000000000',
canEditContent: true,
canManage: false
}
);
});
test('家族圈页面提供真实详情、分页和上传控件,不暴露 OSS ID 或草稿伪语义', () => {
const listPage = read('profile-feed.html');
const editPage = read('profile-feed-edit.html');
const detailPage = read('profile-feed-detail.html');
const script = read('public/js/feed-pages.js');
assert.match(listPage, /data-feed-page-prev/);
assert.match(listPage, /data-feed-page-next/);
assert.match(listPage, /data-feed-create-link[^>]+data-genealogy-context-link|data-genealogy-context-link[^>]+data-feed-create-link/);
assert.match(detailPage, /data-feed-detail-page/);
assert.match(detailPage, /data-feed-detail/);
assert.match(detailPage, /data-feed-detail-comments/);
assert.match(detailPage, /data-feed-list-link[^>]+data-genealogy-context-link|data-genealogy-context-link[^>]+data-feed-list-link/);
assert.doesNotMatch(editPage, /type="text"[^>]+name="mediaOssIds"|name="mediaOssIds"[^>]+type="text"/);
assert.match(editPage, /name="mediaOssIds"[^>]+type="hidden"|type="hidden"[^>]+name="mediaOssIds"/);
assert.match(editPage, /multiple/);
assert.match(editPage, /data-feed-advanced[^>]+hidden/);
assert.match(editPage, /name="status"[^>]+disabled|disabled[^>]+name="status"/);
assert.doesNotMatch(editPage, /草稿|图片 OSS ID/);
assert.match(script, /feedCommentsPage/);
assert.match(script, /feedCommentRepliesPage/);
assert.match(script, /currentProfile/);
assert.match(script, /genealogyDetail/);
assert.match(script, /field\.disabled/);
});
+193 -9
View File
@@ -14,19 +14,203 @@ test('家谱入口只接受本地家谱业务页作为返回目标', () => {
assert.equal(GenealogyEntryPages.getTargetPage('?next=https%3A%2F%2Fevil.example'), 'profile-family-home.html');
});
test('家谱入口只展示当前 PC 已定义的额度,不伪造家谱编号', () => {
const message = GenealogyEntryPages.buildStatus({ createRemaining: 1, joinRemaining: 2 }, 'profile-feed.html');
test('家谱入口只使用 AppGenealogyVo 的真实字段并保持大整数 ID 字符串', () => {
assert.deepEqual(GenealogyEntryPages.normalizeGenealogy({
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县',
memberCount: 12,
personCount: 34,
roleType: 'owner',
canManage: true,
legacyId: 'forbidden'
}), {
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县',
memberCount: 12,
personCount: 34,
roleType: 'owner',
canManage: true
});
assert.equal(
GenealogyEntryPages.buildEntryUrl('2062179707935264769', 'profile-feed.html'),
'profile-feed.html?genealogyId=2062179707935264769'
);
});
assert.match(message, /还可创建 1 部/);
assert.match(message, /还可加入 2 部/);
assert.match(message, /不能伪造家谱编号/);
test('家谱创建只构造 AppGenealogyCreateBody 并省略空可选字段', () => {
assert.deepEqual(GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: ' 汤氏家谱 ',
surname: ' 汤 ',
regionCode: '511622',
ancestralHall: '',
originPlace: ' 四川 ',
addressDetail: '',
coverOssId: '2062179707935264769',
intro: ' 家谱简介 ',
visibility: '1',
joinMode: '1',
genealogyId: 'must-drop',
ownerUserId: 'must-drop'
}), {
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
originPlace: '四川',
coverOssId: '2062179707935264769',
intro: '家谱简介',
visibility: '1',
joinMode: '1'
});
});
test('家谱创建校验必填、枚举、上传派生 ID 和真实额度', () => {
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({ surname: '汤', regionCode: '511622' })
),
'请填写谱名'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({ genealogyName: '汤氏家谱', regionCode: '511622' })
),
'请填写姓氏'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({ genealogyName: '汤氏家谱', surname: '汤' })
),
'请选择家谱所在地区'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
visibility: '9'
})
),
'可见范围选项无效'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
joinMode: '9'
})
),
'加入方式选项无效'
);
assert.equal(
GenealogyEntryPages.validateGenealogyCreateBody(
GenealogyEntryPages.buildGenealogyCreateBody({
genealogyName: '汤氏家谱',
surname: '汤',
regionCode: '511622',
coverOssId: Number.MAX_SAFE_INTEGER + 1
})
),
'封面文件无效,请重新选择'
);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: 1 }), true);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: 0 }), false);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: -1, canCreate: true }), true);
assert.equal(GenealogyEntryPages.canCreateGenealogy({ createRemaining: 3, canCreate: false }), false);
});
test('创建响应必须提供稳定家谱 ID、谱名和姓氏', () => {
const created = GenealogyEntryPages.normalizeCreatedGenealogy({
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县',
status: '0'
});
assert.deepEqual(created, {
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱',
surname: '汤',
regionFullName: '四川省广安市武胜县'
});
assert.equal(
GenealogyEntryPages.buildCreatedGenealogyUrl(created),
'profile-family-home.html?genealogyId=2062179707935264769'
);
assert.equal(GenealogyEntryPages.normalizeCreatedGenealogy({
genealogyId: Number.MAX_SAFE_INTEGER + 1,
genealogyName: '汤氏家谱',
surname: '汤'
}), null);
assert.equal(GenealogyEntryPages.normalizeCreatedGenealogy({
genealogyId: '2062179707935264769',
genealogyName: '汤氏家谱'
}), null);
});
test('负数额度不向用户展示为可用数量', () => {
assert.doesNotMatch(
GenealogyEntryPages.buildStatus({ createRemaining: -1, joinRemaining: -1 }, 'profile-feed.html'),
/-1/
);
const status = GenealogyEntryPages.buildStatus({ createRemaining: -1, joinRemaining: -1 });
assert.doesNotMatch(status, /-1/);
assert.match(status, /创建不限/);
assert.match(status, /加入不限/);
});
test('我的家谱页是可用的真实列表入口', () => {
const source = read('profile-families.html');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.doesNotMatch(source, /pending-pages\.js/);
assert.match(source, /data-genealogy-list="mine"/);
assert.match(source, /genealogy-entry-pages\.js/);
});
test('创建家谱页开放真实 PC 表单且不允许手填业务 ID', () => {
const source = read('profile-create-family.html');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.doesNotMatch(source, /pending-pages\.js/);
assert.match(source, /data-genealogy-create-page/);
assert.match(source, /data-genealogy-create-quota/);
assert.match(source, /data-genealogy-create-status/);
assert.match(source, /name="coverOssId"\s+type="hidden"/);
assert.match(source, /data-upload-target="#familyCoverOssId"/);
assert.match(source, /public\/js\/region-pages\.js/);
assert.match(source, /public\/js\/md5\.js/);
assert.match(source, /public\/js\/upload-pages\.js/);
assert.match(source, /public\/js\/genealogy-entry-pages\.js/);
assert.doesNotMatch(source, /name="(?:genealogyId|applyId|inviterUserId)"/);
assert.doesNotMatch(source, /name="coverOssId"[^>]*type="text"/);
});
test('加入家谱页面不允许用户手工填写 genealogyId', () => {
const source = read('join-genealogy.html');
assert.doesNotMatch(source, /name="genealogyId"/);
assert.doesNotMatch(source, /请输入家谱ID/);
});
test('家谱业务模块运行时统一委托 ProfileUI 读取和传播上下文', () => {
[
'feed-pages.js',
'growth-pages.js',
'lineage-pages.js',
'memo-pages.js',
'relative-pages.js',
'generation-pages.js'
].forEach((file) => {
const source = read('public/js/' + file);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.getGenealogyId/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.syncGenealogyContextLinks/);
});
});
test('个人中心和内容发布在缺少家谱上下文时统一进入家谱入口', () => {
+114 -1
View File
@@ -74,6 +74,32 @@ test('字辈列表要求管理接口返回稳定的直接字段', () => {
}), null);
});
test('字辈响应保留页面闭环所需字段并保持所有业务 ID 为字符串', () => {
assert.deepEqual(GenerationPages.normalizeGenerationPoem({
poemId: '2060000000000000000',
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
generationNo: 3,
generationText: '万',
description: '第三世',
sortOrder: 30,
status: '0',
remark: '祖训'
}), {
poemId: '2060000000000000000',
genealogyId: '2061000000000000000',
genealogyNo: 'G20260728001',
genealogyName: '叶氏家谱',
generationNo: 3,
generationText: '万',
status: '0',
description: '第三世',
sortOrder: 30,
remark: '祖训'
});
});
test('字辈批量输入按 Apifox 限制校验', () => {
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 0, generationText: '万' }), '世代序号必须是 1 到 2147483647 之间的整数');
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 3, generationText: '' }), '请填写字辈文字');
@@ -81,16 +107,103 @@ test('字辈批量输入按 Apifox 限制校验', () => {
assert.equal(GenerationPages.validateGenerationPoemBody({ generationNo: 3, generationText: '万', sortOrder: 2147483648 }), '排序值必须在 -2147483648 到 2147483647 之间');
assert.equal(GenerationPages.validateBatchBody({ poemText: '', disableMissing: false }), '请填写批量字辈内容');
assert.deepEqual(GenerationPages.splitPoemText('德,承;家、亦/传|芳\n远'), ['德', '承', '家', '亦', '传', '芳', '远']);
assert.equal(GenerationPages.validateBatchBody({ poemText: '甲'.repeat(51), disableMissing: false }), '单个字辈不能超过 50 个字符');
assert.deepEqual(GenerationPages.splitPoemText('德承家亦'), ['德', '承', '家', '亦']);
assert.equal(GenerationPages.validateBatchBody({ poemText: '甲'.repeat(51) + ' 德', disableMissing: false }), '单个字辈不能超过 50 个字符');
assert.equal(GenerationPages.validateBatchBody({ poemText: Array(502).fill('甲').join(' '), disableMissing: false }), '一次最多导入 500 个世代');
assert.equal(GenerationPages.validateBatchBody({ poemText: '德承家亦', disableMissing: false }), '');
});
test('家谱内容权限决定读取正常列表还是包含停用项的管理列表', () => {
assert.deepEqual(GenerationPages.normalizeGenerationAccess({ canEditContent: false }), {
canEditContent: false,
listMethod: 'generationPoems'
});
assert.deepEqual(GenerationPages.normalizeGenerationAccess({ canEditContent: true }), {
canEditContent: true,
listMethod: 'generationPoemsManagement'
});
});
test('批量预览逐项校验动作、状态、ID 与汇总计数', () => {
assert.deepEqual(GenerationPages.normalizePreview({
createCount: 1,
updateCount: 0,
keepCount: 0,
disableCount: 1,
items: [
{
generationNo: 1,
newGenerationText: '德',
newStatus: '0',
action: 'create'
},
{
poemId: '2060000000000000000',
generationNo: 2,
oldGenerationText: '承',
oldStatus: '0',
newStatus: '1',
action: 'disable',
warning: '保存后该世代会停用,不会删除历史记录'
}
]
}), {
createCount: 1,
updateCount: 0,
keepCount: 0,
disableCount: 1,
items: [
{
generationNo: 1,
newGenerationText: '德',
newStatus: '0',
action: 'create'
},
{
poemId: '2060000000000000000',
generationNo: 2,
oldGenerationText: '承',
oldStatus: '0',
newStatus: '1',
action: 'disable',
warning: '保存后该世代会停用,不会删除历史记录'
}
]
});
assert.equal(GenerationPages.normalizePreview({ createCount: 0, updateCount: 0, keepCount: 0, disableCount: 0, items: [] }), null);
assert.equal(GenerationPages.normalizePreview({
createCount: 1,
updateCount: 0,
keepCount: 0,
disableCount: 0,
items: [{ generationNo: 1, action: 'create' }]
}), null);
assert.equal(GenerationPages.normalizePreview({
createCount: 2,
updateCount: 0,
keepCount: 0,
disableCount: 0,
items: [{ generationNo: 1, newGenerationText: '德', newStatus: '0', action: 'create' }]
}), null);
});
test('停用缺失世代的批量保存确认文案明确不会删除历史', () => {
assert.equal(
GenerationPages.buildBatchConfirmMessage(true),
'确认按当前预览保存字辈,并停用未出现在文本中的后续世代吗?历史记录不会删除。'
);
assert.equal(GenerationPages.buildBatchConfirmMessage(false), '确认按当前预览保存字辈吗?');
});
test('字辈批量示例明确使用 Apifox 支持的分隔符', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-generation.html'), 'utf8');
const styles = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(page, /placeholder="例如:德 承 家 亦(用空格或标点分隔)"/);
assert.doesNotMatch(page, /placeholder="例如:德承家亦"/);
assert.match(page, /data-generation-management[^>]+hidden/);
assert.match(page, /data-generation-add[^>]+disabled|disabled[^>]+data-generation-add/);
assert.match(styles, /\[data-generation-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
});
test('字辈管理接口的 403 不是登录失效', () => {
+176 -27
View File
@@ -1,51 +1,200 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const GrowthPages = require('../public/js/growth-pages.js');
test('成长记录页只从 URL 读取真实家谱编号', () => {
assert.equal(GrowthPages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
test('成长记录页只从 URL 读取安全的真实家谱和记录编号', () => {
assert.equal(
GrowthPages.getCurrentGenealogyId('?genealogyId=2060000000000000001&recordId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
GrowthPages.getCurrentRecordId('?genealogyId=2060000000000000001&recordId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(GrowthPages.getCurrentGenealogyId(''), '');
assert.equal(GrowthPages.getCurrentRecordId('?recordId=unsafe'), '');
});
test('成长记录写入只构造 Apifox GrowthRecordBody 字段', () => {
test('成长记录写入只构造 YAML GrowthRecordBody 字段并转换后端日期格式', () => {
assert.deepEqual(
GrowthPages.buildGrowthRecordBody({
lineagePersonId: '2060000000000000001',
recordType: 'birth',
recordTitle: ' 出生记录 ',
recordContent: ' 平安出生 ',
lineagePersonId: '2060000000000000003',
recordType: ' 入学 ',
recordTitle: ' 入学记录 ',
recordContent: ' 顺利入学 ',
recordDate: '2026-07-24',
remindTime: '2026-07-24T09:00:00+08:00',
mediaOssIds: '101,102',
remindTime: '2026-07-24T09:30',
mediaOssIds: '2060000000000000004,2060000000000000005',
sortOrder: '3',
status: 'enabled',
content: '旧字段',
personId: '旧字段'
status: '0',
recordId: 'should-not-send',
appUserId: 'should-not-send'
}),
{
lineagePersonId: '2060000000000000001',
recordType: 'birth',
recordTitle: '出生记录',
recordContent: '平安出生',
lineagePersonId: '2060000000000000003',
recordType: '入学',
recordTitle: '入学记录',
recordContent: '顺利入学',
recordDate: '2026-07-24',
remindTime: '2026-07-24T09:00:00+08:00',
mediaOssIds: '101,102',
remindTime: '2026-07-24 09:30:00',
mediaOssIds: '2060000000000000004,2060000000000000005',
sortOrder: 3,
status: 'enabled'
status: '0'
}
);
});
test('成长记录校验标题、整数 ID、附件 OSS ID 和排序值', () => {
test('成长记录校验标题、选择器 ID、附件、排序和可重读状态', () => {
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '' }), '请填写记录标题');
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', lineagePersonId: 'A-1' }), '世系人物 ID 必须是整数');
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101, 102' }), '附件 OSS ID 请使用英文逗号分隔的正整数');
assert.equal(GrowthPages.validateGrowthRecordBody(GrowthPages.buildGrowthRecordBody({ recordTitle: '记录', sortOrder: '1.5' })), '排序值必须是安全整数');
assert.equal(GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101,102', sortOrder: 1 }), '');
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', lineagePersonId: 'A-1' }),
'请选择有效的世系人物'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101, 102' }),
'附件上传结果无效'
);
assert.equal(
GrowthPages.validateGrowthRecordBody(GrowthPages.buildGrowthRecordBody({ recordTitle: '记录', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', recordDate: '2026/07/24' }),
'记录日期格式无效'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', remindTime: '2026-07-24T09:30:00+08:00' }),
'提醒时间格式无效'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', status: '1' }),
'当前 PC 无法重新读取停用记录,暂不开放停用'
);
assert.equal(
GrowthPages.validateGrowthRecordBody({ recordTitle: '记录', mediaOssIds: '101,102', sortOrder: 1, status: '0' }),
''
);
});
test('成长记录列表不猜测未展开的响应 DTO', () => {
assert.deepEqual(GrowthPages.normalizeGrowthList([{ property1: 'value' }]), [{ property1: 'value' }]);
assert.deepEqual(GrowthPages.normalizeGrowthList({ rows: [] }), []);
test('成长记录写后重读必须返回同一条稳定记录', () => {
const detail = {
recordId: '2060000000000000001',
genealogyId: '2060000000000000002',
recordTitle: '入学记录',
status: '0'
};
assert.equal(GrowthPages.matchesSavedRecord(detail, '2060000000000000001'), true);
assert.equal(GrowthPages.matchesSavedRecord(detail, '2060000000000000009'), false);
assert.equal(GrowthPages.matchesSavedRecord({}, '2060000000000000001'), false);
});
test('成长记录响应使用 PC GrowthRecordVo 并拒绝不安全长 ID', () => {
assert.deepEqual(GrowthPages.normalizeGrowthRecord({
recordId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
lineagePersonId: '2060000000000000004',
lineagePersonNo: 'P001',
lineagePersonName: '叶小明',
recordType: '入学',
recordTitle: '小学入学',
recordContent: '<p>第一天上学</p>',
recordDate: '2026-09-01 00:00:00',
remindTime: '2027-09-01 08:30:00',
mediaOssIds: '2060000000000000005,2060000000000000006',
sortOrder: 1,
status: '0',
remark: '成长节点'
}), {
recordId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
lineagePersonId: '2060000000000000004',
lineagePersonNo: 'P001',
lineagePersonName: '叶小明',
recordType: '入学',
recordTitle: '小学入学',
recordContent: '<p>第一天上学</p>',
recordDate: '2026-09-01 00:00:00',
remindTime: '2027-09-01 08:30:00',
mediaOssIds: '2060000000000000005,2060000000000000006',
sortOrder: 1,
status: '0',
remark: '成长节点'
});
assert.equal(GrowthPages.normalizeGrowthRecord({
recordId: Number('2060000000000000001'),
genealogyId: '2',
recordTitle: '标题',
status: '0'
}), null);
});
test('成长记录渲染业务字段但不暴露手机号、OSS ID 或未转义正文', () => {
const record = GrowthPages.normalizeGrowthRecord({
recordId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
lineagePersonId: '4',
lineagePersonName: '叶小明',
recordType: '入学',
recordTitle: '<script>alert(1)</script>',
recordContent: '<img src=x onerror=alert(1)>第一天上学',
recordDate: '2026-09-01 00:00:00',
mediaOssIds: '5,6',
sortOrder: 1,
status: '0'
});
const html = GrowthPages.renderGrowthDetail(record);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /第一天上学/);
assert.match(html, /叶小明/);
assert.match(html, /2 个附件/);
assert.doesNotMatch(html, /19100000000|>5<|>6</);
});
test('世系人物选择器只使用稳定人物编号和名称', () => {
assert.equal(
GrowthPages.renderLineageOptions([
{ personId: '2060000000000000001', name: '叶小明', generationName: '承' },
{ personId: Number('2060000000000000002'), name: '不安全编号' }
], '2060000000000000001'),
'<option value="">不关联世系人物</option><option value="2060000000000000001" selected>叶小明 · 承</option>'
);
});
test('成长记录列表和编辑页开放 PC CRUD 且不允许手填人物或 OSS ID', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-growth.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-growth-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(listPage, /data-growth-list/);
assert.match(listPage, /data-growth-detail/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="lineagePersonId"[^>]*data-growth-lineage-person/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.doesNotMatch(editPage, /name="(?:lineagePersonId|mediaOssIds)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/growth-pages\.js/);
assert.match(styles, /\[data-growth-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+243 -6
View File
@@ -13,33 +13,67 @@ test('世系页只从 URL 读取真实家谱编号', () => {
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
assert.deepEqual(
LineagePages.buildLineagePersonBody({
bindingMode: 'SPECIFIED',
appUserId: '2061000000000000001',
avatarOssId: '2061000000000000002',
name: '李明',
sex: '0',
generation: '3',
generationName: '德',
fatherId: '2061000000000000003',
motherId: '2061000000000000004',
personNo: 'P202607090001',
aliasName: '明远',
birthDate: '1990-01-01',
birthDate: '1990-01-01T08:30',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03T10:45',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
sortOrder: '1',
relationName: '妻',
biography: '人物简介',
remark: '家族备注',
personName: '旧字段',
generationNo: 4,
introduction: '旧字段'
}),
{
bindingMode: 'SPECIFIED',
appUserId: '2061000000000000001',
avatarOssId: '2061000000000000002',
name: '李明',
sex: '0',
generation: 3,
generationName: '德',
fatherId: '2061000000000000003',
motherId: '2061000000000000004',
personNo: 'P202607090001',
aliasName: '明远',
birthDate: '1990-01-01',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
sortOrder: 1,
relationName: '妻',
biography: '人物简介'
biography: '人物简介',
remark: '家族备注'
}
);
assert.deepEqual(LineagePages.buildLineagePersonBody({
bindingMode: 'SELF',
appUserId: '2061000000000000001',
name: '李明'
}), {
bindingMode: 'SELF',
name: '李明'
});
});
test('世系人物要求安全的 ID、姓名和有效世代值', () => {
@@ -60,9 +94,168 @@ test('世系人物要求安全的 ID、姓名和有效世代值', () => {
}
);
assert.equal(LineagePages.normalizeLineagePerson({ personId: Number('2060000000000000000'), name: '李明' }), null);
assert.equal(LineagePages.validateLineagePersonBody({ name: '' }), '请填写成员姓名');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', generation: '1.5' })), '世代序号必须是整数');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ name: '李明', sortOrder: '1.5' })), '排序值必须是整数');
assert.equal(LineagePages.normalizeLineagePerson({
personId: '2060000000000000000',
fatherId: Number('2061000000000000000'),
name: '李明'
}), null);
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '' }), '请填写成员姓名');
assert.equal(LineagePages.validateLineagePersonBody({ name: '李明' }), '请选择账号绑定方式');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'OTHER', name: '李明' }), '账号绑定方式无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'SPECIFIED', name: '李明' }), '指定账号绑定缺少用户选项');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ bindingMode: 'NONE', name: '李明', generation: '1.5' })), '世代序号必须是整数');
assert.equal(LineagePages.validateLineagePersonBody(LineagePages.buildLineagePersonBody({ bindingMode: 'NONE', name: '李明', sortOrder: '1.5' })), '排序值必须是整数');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', sex: '3' }), '性别选项无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', personStatus: '3' }), '人物状态选项无效');
assert.equal(LineagePages.validateLineagePersonBody({ bindingMode: 'NONE', name: '李明', birthLunar: '2' }), '出生历法选项无效');
});
test('世系人物响应保留完整详情字段并拒绝不安全的嵌套 ID', () => {
assert.deepEqual(LineagePages.normalizeLineagePerson({
personId: '2060000000000000000',
genealogyId: '2060000000000000001',
appUserId: '2060000000000000002',
avatarOssId: '2060000000000000003',
fatherId: '2060000000000000004',
motherId: '2060000000000000005',
genealogyName: '李氏家谱',
genealogyNo: 'G20260728001',
appUserNickName: '明远',
personNo: 'P202607090001',
name: '李明',
aliasName: '明远',
sex: '0',
generation: 3,
generationName: '德',
fatherName: '李父',
motherName: '王母',
spouseNames: '张氏',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
biography: '人物简介',
sortOrder: 1,
status: '0',
remark: '家族备注'
}), {
personId: '2060000000000000000',
name: '李明',
genealogyName: '李氏家谱',
genealogyNo: 'G20260728001',
appUserNickName: '明远',
personNo: 'P202607090001',
aliasName: '明远',
sex: '0',
generationName: '德',
fatherName: '李父',
motherName: '王母',
spouseNames: '张氏',
birthDate: '1990-01-01 08:30:00',
birthLunar: '0',
birthPlace: '成都',
deathDate: '2050-02-03 10:45:00',
deathLunar: '1',
deathPlace: '重庆',
burialPlace: '青城山',
personStatus: '1',
biography: '人物简介',
status: '0',
remark: '家族备注',
appUserId: '2060000000000000002',
genealogyId: '2060000000000000001',
fatherId: '2060000000000000004',
motherId: '2060000000000000005',
avatarOssId: '2060000000000000003',
generation: 3,
sortOrder: 1
});
});
test('家谱内容权限只控制世系写操作,读取和分页始终可用', () => {
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: false }), {
canEditContent: false
});
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: true }), {
canEditContent: true
});
assert.equal(LineagePages.canUseLineagePagination(false), true);
assert.equal(LineagePages.canUseLineagePagination(true), false);
});
test('世代选项来自正常字辈列表并自动映射序号和字辈', () => {
assert.deepEqual(LineagePages.normalizeGenerationOption({
poemId: '2060000000000000000',
generationNo: 3,
generationText: '德',
status: '0'
}), {
generation: 3,
generationName: '德'
});
assert.equal(LineagePages.normalizeGenerationOption({ generationNo: 0, generationText: '德' }), null);
assert.equal(LineagePages.normalizeGenerationOption({ generationNo: 3, generationText: '' }), null);
});
test('已有账号绑定按当前登录用户区分 SELF 与只读保留的 SPECIFIED', () => {
assert.deepEqual(LineagePages.normalizeLineageBinding({}, '2060000000000000001'), {
bindingMode: 'NONE',
appUserId: ''
});
assert.deepEqual(LineagePages.normalizeLineageBinding({
appUserId: '2060000000000000001'
}, '2060000000000000001'), {
bindingMode: 'SELF',
appUserId: ''
});
assert.deepEqual(LineagePages.normalizeLineageBinding({
appUserId: '2060000000000000002'
}, '2060000000000000001'), {
bindingMode: 'SPECIFIED',
appUserId: '2060000000000000002'
});
});
test('指定账号绑定只使用当前家谱正常且已绑定账号的成员选项', () => {
assert.deepEqual(LineagePages.normalizeGenealogyMemberOption({
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '明远',
memberName: '李明',
roleType: 'member',
status: '0'
}), {
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '明远',
memberName: '李明',
roleType: 'member',
status: '0'
});
assert.equal(LineagePages.normalizeGenealogyMemberOption({
memberId: '1',
genealogyId: '2',
appUserId: null,
memberName: '未绑定成员',
status: '0'
}), null);
assert.equal(LineagePages.normalizeGenealogyMemberOption({
memberId: '1',
genealogyId: '2',
appUserId: '3',
memberName: '停用成员',
status: '1'
}), null);
assert.deepEqual(LineagePages.normalizeGenealogyMemberOptions([
{ memberId: '1', genealogyId: '2', appUserId: '3', memberName: '正常成员', status: '0' },
{ memberId: '4', genealogyId: '2', appUserId: null, memberName: '未绑定成员', status: '0' }
]).map((option) => option.appUserId), ['3']);
});
test('世系关系模式只使用 PC 已定义的四种新增关系', () => {
@@ -76,12 +269,56 @@ test('世系关系模式只使用 PC 已定义的四种新增关系', () => {
test('世系页按 Apifox 关键词范围搜索并提供分页入口', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
assert.deepEqual(LineagePages.buildLineagePageQuery({
keyword: ' 李明 ',
generation: '3',
personStatus: '1'
}, 2, 20), {
pageNum: 2,
pageSize: 20,
keyword: '李明',
generation: 3,
personStatus: '1'
});
assert.deepEqual(LineagePages.buildLineagePageQuery({}, 1, 20), {
pageNum: 1,
pageSize: 20
});
assert.match(page, /placeholder="输入姓名、别名或人物编号搜索"/);
assert.match(page, /data-lineage-generation-filter/);
assert.match(page, /data-lineage-status-filter/);
assert.match(page, /data-lineage-pagination/);
assert.match(page, /data-lineage-page-action="previous"/);
assert.match(page, /data-lineage-page-action="next"/);
});
test('世系表单为全部 LineagePersonBody 字段提供真实来源且不允许手填业务 ID', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
const styles = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'profile-module.css'), 'utf8');
[
'bindingMode', 'appUserId', 'avatarOssId', 'personNo', 'name', 'aliasName', 'sex',
'generation', 'generationName', 'fatherId', 'motherId', 'birthDate', 'birthLunar',
'birthPlace', 'deathDate', 'deathLunar', 'deathPlace', 'burialPlace', 'personStatus',
'biography', 'sortOrder', 'remark', 'relationName'
].forEach((name) => assert.match(page, new RegExp('name="' + name + '"')));
assert.match(page, /value="SPECIFIED"(?![^>]+disabled)/);
assert.match(page, /select[^>]+name="appUserId"[^>]+data-lineage-member-options|select[^>]+data-lineage-member-options[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="(?:text|number)"|type="(?:text|number)"[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="hidden"|type="hidden"[^>]+name="appUserId"/);
assert.match(page, /name="avatarOssId"[^>]+type="hidden"|type="hidden"[^>]+name="avatarOssId"/);
assert.match(page, /data-upload-target="#lineage-avatar-oss-id"/);
assert.match(page, /name="generation"[^>]*data-lineage-generation/);
assert.match(page, /name="generationName"[^>]+readonly|readonly[^>]+name="generationName"/);
assert.match(page, /name="fatherId"[^>]*data-lineage-parent-option/);
assert.match(page, /name="motherId"[^>]*data-lineage-parent-option/);
assert.match(page, /data-lineage-management[^>]+hidden/);
assert.match(page, /public\/js\/upload-pages\.js/);
assert.match(styles, /\[data-lineage-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-spouse-field\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-pagination\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
test('世系管理的 403 不会被当作登录失效', () => {
assert.equal(LineagePages.isForbidden({ status: 403 }), true);
assert.equal(LineagePages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);
+176
View File
@@ -0,0 +1,176 @@
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', 'member-admin-pages.js');
const MemberAdminPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof MemberAdminPages[name], 'function', `缺少 MemberAdminPages.${name}`);
return MemberAdminPages[name];
}
test('成员管理页只从家谱上下文读取安全 ID', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
assert.equal(getCurrentGenealogyId('?genealogyId=2060000000000000001'), '2060000000000000001');
assert.equal(getCurrentGenealogyId('?genealogyId=unsafe'), '');
assert.equal(getCurrentGenealogyId(''), '');
});
test('成员响应使用完整 GenealogyMemberVo 并保持长 ID 为字符串', () => {
const normalizeMember = requireFunction('normalizeMember');
const member = normalizeMember({
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
lineagePersonId: '2060000000000000004',
appUserNickName: '明远',
appUserPhone: '19100000000',
lineagePersonName: '李明',
memberName: '李先生',
roleType: 'member',
relationName: '族亲',
joinSource: 'invite',
inviterUserId: '2060000000000000005',
inviterNickName: '谱主',
inviterPhone: '19111111111',
joinTime: '2026-07-29 10:00:00',
status: '0'
});
assert.equal(member.memberId, '2060000000000000001');
assert.equal(member.appUserId, '2060000000000000003');
assert.equal(member.lineagePersonId, '2060000000000000004');
assert.equal(normalizeMember({
memberId: Number('2060000000000000001'),
genealogyId: '2',
appUserId: '3',
roleType: 'member',
status: '0'
}), null);
});
test('成员修改只构造后端可更新字段并收紧角色枚举', () => {
const buildMemberUpdateBody = requireFunction('buildMemberUpdateBody');
const validateMemberUpdateBody = requireFunction('validateMemberUpdateBody');
assert.deepEqual(buildMemberUpdateBody({
memberName: ' 李先生 ',
relationName: ' 族亲 ',
roleType: 'editor',
lineagePersonId: '2060000000000000004',
memberId: 'must-drop',
appUserId: 'must-drop',
status: '3'
}), {
memberName: '李先生',
relationName: '族亲',
roleType: 'editor',
lineagePersonId: '2060000000000000004'
});
assert.equal(validateMemberUpdateBody(buildMemberUpdateBody({ memberName: '名'.repeat(51) })), '成员名称不能超过 50 个字符');
assert.equal(validateMemberUpdateBody(buildMemberUpdateBody({ relationName: '关系'.repeat(51) })), '关系名称不能超过 100 个字符');
assert.throws(
() => buildMemberUpdateBody({ roleType: 'owner' }),
/成员角色只能是管理员、编辑或成员/
);
assert.equal(
validateMemberUpdateBody(buildMemberUpdateBody({ lineagePersonId: Number.MAX_SAFE_INTEGER + 1 })),
'世系人物选项无效'
);
});
test('成员管理动作遵守谱主和管理员的后端权限边界', () => {
const canEditMember = requireFunction('canEditMember');
const canRemoveMember = requireFunction('canRemoveMember');
const canLeaveGenealogy = requireFunction('canLeaveGenealogy');
const canTransferOwner = requireFunction('canTransferOwner');
assert.equal(canEditMember('owner', 'admin'), true);
assert.equal(canEditMember('admin', 'admin'), false);
assert.equal(canEditMember('admin', 'member'), true);
assert.equal(canEditMember('owner', 'owner'), false);
assert.equal(canRemoveMember('owner', 'owner'), false);
assert.equal(canRemoveMember('admin', 'admin'), false);
assert.equal(canLeaveGenealogy('owner'), false);
assert.equal(canLeaveGenealogy('member'), true);
assert.equal(canTransferOwner('owner'), true);
assert.equal(canTransferOwner('admin'), false);
});
test('谱主转移只使用成员列表中的真实目标 memberId', () => {
const buildOwnerTransferBody = requireFunction('buildOwnerTransferBody');
const validateOwnerTransferBody = requireFunction('validateOwnerTransferBody');
assert.deepEqual(buildOwnerTransferBody('2060000000000000001'), {
targetMemberId: '2060000000000000001'
});
assert.equal(validateOwnerTransferBody(buildOwnerTransferBody('')), '请选择新谱主');
assert.equal(validateOwnerTransferBody(buildOwnerTransferBody('unsafe')), '请选择新谱主');
assert.equal(
validateOwnerTransferBody(buildOwnerTransferBody('2060000000000000001'), '2060000000000000001'),
'不能转让给自己'
);
});
test('世系人物绑定选项只接受当前家谱响应中的安全人物 ID', () => {
const normalizeLineageOption = requireFunction('normalizeLineageOption');
assert.deepEqual(normalizeLineageOption({
personId: '2060000000000000001',
name: '李明',
generationName: '德'
}), {
personId: '2060000000000000001',
name: '李明',
generationName: '德'
});
assert.equal(normalizeLineageOption({ personId: Number('2060000000000000001'), name: '李明' }), null);
assert.equal(normalizeLineageOption({ personId: '1', name: '' }), null);
});
test('成员列表展示转义内容并隐藏手机号与内部 ID', () => {
const normalizeMember = requireFunction('normalizeMember');
const renderMemberRow = requireFunction('renderMemberRow');
const html = renderMemberRow(normalizeMember({
memberId: '2060000000000000001',
genealogyId: '2060000000000000002',
appUserId: '2060000000000000003',
appUserNickName: '<img src=x>明远',
appUserPhone: '19100000000',
lineagePersonId: '2060000000000000004',
lineagePersonName: '<script>alert(1)</script>李明',
memberName: '李先生',
roleType: 'member',
relationName: '族亲',
inviterUserId: '2060000000000000005',
inviterPhone: '19111111111',
status: '0'
}), {
actorRole: 'member',
currentMemberId: '2060000000000000001'
});
assert.doesNotMatch(html, /<script|<img/);
assert.doesNotMatch(html, /19100000000|19111111111|206000000000000000[1-5]/);
assert.match(html, /李先生|族亲|成员/);
});
test('家族管理页开放成员维护、退出和谱主转移且不允许手填 ID', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-family-admin.html'), 'utf8');
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
assert.match(page, /public\/js\/member-admin-pages\.js/);
assert.match(page, /data-member-list/);
assert.match(page, /data-member-form/);
assert.match(page, /name="memberName"/);
assert.match(page, /name="relationName"/);
assert.match(page, /name="roleType"/);
assert.match(page, /select[^>]+name="lineagePersonId"|name="lineagePersonId"[^>]+select/);
assert.match(page, /data-member-leave/);
assert.match(page, /select[^>]+name="targetMemberId"|name="targetMemberId"[^>]+select/);
assert.doesNotMatch(page, /name="(?:memberId|appUserId)"|type="(?:text|number)"[^>]+name="(?:lineagePersonId|targetMemberId)"/);
assert.doesNotMatch(page, /href="profile-data\.html"[^>]*>\s*添加成员/);
});
+173 -34
View File
@@ -1,45 +1,184 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const MemoPages = require('../public/js/memo-pages.js');
test('备忘录页只从 URL 读取真实家谱编号', () => {
assert.equal(MemoPages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
assert.equal(MemoPages.getCurrentGenealogyId(''), '');
});
test('备忘录写入只构造 Apifox 已核验请求体字段', () => {
assert.deepEqual(
MemoPages.buildMemoBody({
memoTitle: ' 祭祖提醒 ',
memoContent: ' 准备供品 ',
remindTime: '2026-07-24T09:00:00+08:00',
completed: ' 0 ',
mediaOssIds: '101,102',
sortOrder: '3',
status: ' enabled ',
relativeName: '旧字段'
}),
{
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24T09:00:00+08:00',
completed: '0',
mediaOssIds: '101,102',
sortOrder: 3,
status: 'enabled'
}
test('备忘录页只从 URL 读取安全的真实家谱和备忘录编号', () => {
assert.equal(
MemoPages.getCurrentGenealogyId('?genealogyId=2060000000000000001&memoId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
MemoPages.getCurrentMemoId('?genealogyId=2060000000000000001&memoId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(MemoPages.getCurrentGenealogyId(''), '');
assert.equal(MemoPages.getCurrentMemoId('?memoId=unsafe'), '');
});
test('备忘录校验标题、附件 OSS ID 和排序值', () => {
assert.equal(MemoPages.validateMemoBody({ memoTitle: '' }), '请填写备忘标题');
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', mediaOssIds: '101, 102' }), '附件 OSS ID 请使用英文逗号分隔的正整数');
assert.equal(MemoPages.validateMemoBody(MemoPages.buildMemoBody({ memoTitle: '提醒', sortOrder: '1.5' })), '排序值必须是安全整数');
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', mediaOssIds: '101,102', sortOrder: 1 }), '');
test('备忘录写入只构造 YAML MemoBody 并转换后端时间格式', () => {
assert.deepEqual(MemoPages.buildMemoBody({
memoTitle: ' 祭祖提醒 ',
memoContent: ' 准备供品 ',
remindTime: '2026-07-24T09:30',
completed: '1',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: '3',
status: '0',
memoId: 'should-not-send',
appUserId: 'should-not-send'
}), {
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: 3,
status: '0'
});
});
test('备忘录列表不猜测未展开的响应 DTO', () => {
assert.deepEqual(MemoPages.normalizeMemoList([{ property1: 'value' }]), [{ property1: 'value' }]);
assert.deepEqual(MemoPages.normalizeMemoList({ rows: [] }), []);
test('备忘录校验标题、时间、完成状态、附件、排序和可重读状态', () => {
assert.equal(MemoPages.validateMemoBody({ memoTitle: '' }), '请填写备忘录标题');
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', remindTime: '2026-07-24T09:30:00+08:00' }),
'提醒时间格式无效'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', remindTime: '2026-02-30 09:30:00' }),
'提醒时间格式无效'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', completed: '2' }),
'完成状态只能是未完成或已完成'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', mediaOssIds: '101, 102' }),
'附件上传结果无效'
);
assert.equal(
MemoPages.validateMemoBody(MemoPages.buildMemoBody({ memoTitle: '提醒', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
MemoPages.validateMemoBody({ memoTitle: '提醒', status: '1' }),
'当前 PC 无法重新读取停用备忘录,暂不开放停用'
);
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', completed: '0', status: '0' }), '');
assert.equal(MemoPages.validateMemoBody({ memoTitle: '提醒', completed: '1', status: '0' }), '');
});
test('备忘录响应使用 PC MemoVo 并拒绝不安全的数字长整型', () => {
assert.deepEqual(MemoPages.normalizeMemo({
memoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '年度事项'
}), {
memoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
memoTitle: '祭祖提醒',
memoContent: '准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '年度事项'
});
assert.equal(MemoPages.normalizeMemo({
memoId: Number('2060000000000000001'),
genealogyId: '2',
memoTitle: '提醒',
completed: '0',
status: '0'
}), null);
});
test('备忘录渲染业务字段但不暴露手机号、OSS ID 或未转义内容', () => {
const memo = MemoPages.normalizeMemo({
memoId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
memoTitle: '<script>alert(1)</script>',
memoContent: '<img src=x onerror=alert(1)>准备供品',
remindTime: '2026-07-24 09:30:00',
completed: '1',
mediaOssIds: '4,5',
sortOrder: 1,
status: '0',
remark: '年度事项'
});
const html = MemoPages.renderMemoDetail(memo);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /准备供品/);
assert.match(html, /已完成/);
assert.match(html, /2 个附件/);
assert.doesNotMatch(html, /19100000000|>4<|>5</);
});
test('备忘录写后重读必须返回同一条稳定记录', () => {
const detail = {
memoId: '2060000000000000001',
genealogyId: '2060000000000000002',
memoTitle: '祭祖提醒',
completed: '0',
status: '0'
};
assert.equal(MemoPages.matchesSavedMemo(detail, '2060000000000000001'), true);
assert.equal(MemoPages.matchesSavedMemo(detail, '2060000000000000009'), false);
assert.equal(MemoPages.matchesSavedMemo({}, '2060000000000000001'), false);
});
test('备忘录编辑器拒绝不可重读的停用记录', () => {
assert.equal(MemoPages.isEditableMemo({ status: '0' }), true);
assert.equal(MemoPages.isEditableMemo({ status: '1' }), false);
assert.equal(MemoPages.isEditableMemo(null), false);
});
test('备忘录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或业务状态', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-memo.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-memo-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(listPage, /data-memo-list/);
assert.match(listPage, /data-memo-detail/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="remindTime" type="datetime-local"/);
assert.match(editPage, /name="completed"/);
assert.match(editPage, /value="0"/);
assert.match(editPage, /value="1"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.match(editPage, /data-memo-clear-media/);
assert.doesNotMatch(editPage, /name="(?:mediaOssIds|status)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/memo-pages\.js/);
assert.match(styles, /\[data-memo-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+216
View File
@@ -0,0 +1,216 @@
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', 'merit-pages.js');
const MeritPages = fs.existsSync(modulePath) ? require(modulePath) : {};
function requireFunction(name) {
assert.equal(typeof MeritPages[name], 'function', `缺少 MeritPages.${name}`);
return MeritPages[name];
}
test('功德录只从 URL 读取安全的真实家谱和功德记录编号', () => {
const getCurrentGenealogyId = requireFunction('getCurrentGenealogyId');
const getCurrentMeritId = requireFunction('getCurrentMeritId');
assert.equal(
getCurrentGenealogyId('?genealogyId=2060000000000000001&meritId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
getCurrentMeritId('?genealogyId=2060000000000000001&meritId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(getCurrentGenealogyId(''), '');
assert.equal(getCurrentMeritId('?meritId=unsafe'), '');
});
test('功德记录写入只构造 YAML MeritRecordBody 并转换后端时间格式', () => {
const buildMeritRecordBody = requireFunction('buildMeritRecordBody');
assert.deepEqual(buildMeritRecordBody({
donorName: ' 叶明 ',
meritType: 'repair',
meritTitle: ' 修缮宗祠 ',
meritContent: ' 参与修缮 ',
amount: '500.50',
meritTime: '2026-07-26T10:00',
sortOrder: '3',
status: '0',
meritId: 'should-not-send',
appUserId: 'should-not-send',
mediaOssIds: 'should-not-send'
}), {
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与修缮',
amount: 500.5,
meritTime: '2026-07-26 10:00:00',
sortOrder: 3,
status: '0'
});
});
test('功德记录校验必填、枚举、金额精度、时间、排序和可重读状态', () => {
const buildMeritRecordBody = requireFunction('buildMeritRecordBody');
const validateMeritRecordBody = requireFunction('validateMeritRecordBody');
assert.equal(validateMeritRecordBody({ donorName: '', meritTitle: '标题' }), '请填写功德人姓名');
assert.equal(validateMeritRecordBody({ donorName: '叶明', meritTitle: '' }), '请填写功德标题');
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', meritType: 'service' }),
'功德类型无效'
);
assert.equal(
validateMeritRecordBody(buildMeritRecordBody({ donorName: '叶明', meritTitle: '标题', amount: '9007199254740993.01' })),
'功德金额超出浏览器可安全提交的精度'
);
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', meritTime: '2026-02-30 10:00:00' }),
'功德时间格式无效'
);
assert.equal(
validateMeritRecordBody(buildMeritRecordBody({ donorName: '叶明', meritTitle: '标题', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', status: '1' }),
'当前 PC 无法重新读取停用功德记录,暂不开放停用'
);
assert.equal(
validateMeritRecordBody({ donorName: '叶明', meritTitle: '标题', meritType: 'public', amount: -500.25, status: '0' }),
''
);
});
test('功德记录响应使用 PC MeritRecordVo 并保留 BigDecimal 字符串', () => {
const normalizeMeritRecord = requireFunction('normalizeMeritRecord');
assert.deepEqual(normalizeMeritRecord({
meritId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与修缮',
amount: '500.50',
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0',
remark: '年度功德'
}), {
meritId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
donorName: '叶明',
meritType: 'repair',
meritTitle: '修缮宗祠',
meritContent: '参与修缮',
amount: '500.50',
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0',
remark: '年度功德'
});
assert.equal(normalizeMeritRecord({
meritId: Number('2060000000000000001'),
genealogyId: '2',
donorName: '叶明',
meritType: 'donation',
meritTitle: '捐赠',
amount: '1',
status: '0'
}), null);
});
test('功德记录详情转义内容并隐藏手机号和内部编号', () => {
const normalizeMeritRecord = requireFunction('normalizeMeritRecord');
const renderMeritDetail = requireFunction('renderMeritDetail');
const record = normalizeMeritRecord({
meritId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
donorName: '<script>alert(1)</script>',
meritType: 'public',
meritTitle: '公益助学',
meritContent: '<img src=x onerror=alert(1)>资助学生',
amount: '500.50',
meritTime: '2026-07-26 10:00:00',
sortOrder: 1,
status: '0'
});
const html = renderMeritDetail(record);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /资助学生/);
assert.match(html, /公益/);
assert.match(html, /500\.50/);
assert.doesNotMatch(html, /19100000000|>1<|>2<|>3</);
});
test('功德记录写后重读必须返回同一条稳定记录', () => {
const matchesSavedMerit = requireFunction('matchesSavedMerit');
const detail = {
meritId: '2060000000000000001',
genealogyId: '2060000000000000002',
donorName: '叶明',
meritType: 'donation',
meritTitle: '捐赠',
amount: '500',
meritTime: '2026-07-26 10:00:00',
status: '0'
};
assert.equal(matchesSavedMerit(detail, '2060000000000000001'), true);
assert.equal(matchesSavedMerit(detail, '2060000000000000009'), false);
assert.equal(matchesSavedMerit({}, '2060000000000000001'), false);
});
test('功德记录编辑器拒绝不可重读的停用记录', () => {
const isEditableMerit = requireFunction('isEditableMerit');
assert.equal(isEditableMerit({ status: '0' }), true);
assert.equal(isEditableMerit({ status: '1' }), false);
assert.equal(isEditableMerit(null), false);
});
test('功德录页面开放 PC CRUD 且不制造附件和契约外类型', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-merit.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-merit-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(projectRoot, 'public', 'css', 'profile-module.css'), 'utf8');
assert.doesNotMatch(listPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(listPage, /data-merit-list/);
assert.match(listPage, /data-merit-detail/);
assert.doesNotMatch(editPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(editPage, /name="meritTime" type="datetime-local"/);
assert.match(editPage, /value="donation"/);
assert.match(editPage, /value="repair"/);
assert.match(editPage, /value="public"/);
assert.match(editPage, /value="other"/);
assert.doesNotMatch(editPage, /value="service"/);
assert.match(editPage, /name="amount" type="number" step="any"/);
assert.match(editPage, /name="sortOrder"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.doesNotMatch(editPage, /mediaOssIds|type="file"|upload-pages\.js/);
assert.match(editPage, /public\/js\/rich-editor\.js/);
assert.match(editPage, /public\/js\/merit-pages\.js/);
assert.match(styles, /\[data-merit-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+98 -11
View File
@@ -5,22 +5,109 @@ const test = require('node:test');
const NotificationPages = require('../public/js/notification-pages.js');
test('notification page preserves only the unexpanded PC list records and unread count', () => {
const rows = [{ notificationId: '2060000000000000001', title: '系统通知' }];
function notificationFixture(overrides) {
return Object.assign({
notificationId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
senderUserId: '2060000000000000003',
senderNickName: '叶子',
senderPhone: '19100000000',
noticeType: 'family_feed_comment',
noticeTitle: '新的评论',
noticeContent: '有人评论了家族动态',
bizType: 'family_feed_comment',
bizId: '2060000000000000004',
bizSummary: '家族圈评论',
publishTime: '2026-07-29 10:30:00',
readStatus: '0',
readTime: null,
status: '0',
remark: ''
}, overrides || {});
}
assert.deepEqual(NotificationPages.normalizeNotifications(rows), rows);
assert.deepEqual(NotificationPages.normalizeNotifications({ rows }), []);
assert.equal(NotificationPages.getUnreadCount(3), 3);
assert.equal(NotificationPages.getUnreadCount(-1), 0);
assert.equal(NotificationPages.formatNotification(rows[0]), '{\n "notificationId": "2060000000000000001",\n "title": "系统通知"\n}');
test('通知筛选只允许全部、未读和已读三种查询', () => {
assert.deepEqual(NotificationPages.buildNotificationQuery(''), {});
assert.deepEqual(NotificationPages.buildNotificationQuery('0'), { readStatus: '0' });
assert.deepEqual(NotificationPages.buildNotificationQuery('1'), { readStatus: '1' });
assert.deepEqual(NotificationPages.buildNotificationQuery('unsafe'), {});
});
test('message page loads the PC notification module instead of pending-page controls', () => {
test('通知列表只接受 PC 的非分页数组并规范完整 NotificationView', () => {
const record = notificationFixture();
assert.deepEqual(NotificationPages.normalizeNotifications([record]), [
NotificationPages.normalizeNotification(record)
]);
assert.deepEqual(NotificationPages.normalizeNotifications({ rows: [record] }), []);
assert.equal(NotificationPages.normalizeNotification({
...record,
notificationId: Number('2060000000000000001')
}), null);
assert.equal(NotificationPages.normalizeNotification({
...record,
readStatus: '2'
}), null);
});
test('通知可选业务 ID 接受 null 但拒绝不安全数字', () => {
assert.ok(NotificationPages.normalizeNotification(notificationFixture({
genealogyId: null,
senderUserId: null,
bizId: null
})));
assert.equal(NotificationPages.normalizeNotification(notificationFixture({
bizId: Number('2060000000000000004')
})), null);
});
test('未读数只接受非负安全整数', () => {
assert.equal(NotificationPages.getUnreadCount(3), 3);
assert.equal(NotificationPages.getUnreadCount('3'), 3);
assert.equal(NotificationPages.getUnreadCount(-1), 0);
assert.equal(NotificationPages.getUnreadCount(1.5), 0);
assert.equal(NotificationPages.getUnreadCount(Number.MAX_SAFE_INTEGER + 1), 0);
});
test('通知列表和详情转义正文且不暴露手机号或内部 ID', () => {
const record = NotificationPages.normalizeNotification(notificationFixture({
noticeTitle: '<script>alert(1)</script>',
noticeContent: '<img src=x onerror=alert(1)>通知正文'
}));
const rowHtml = NotificationPages.renderNotificationRow(record);
const detailHtml = NotificationPages.renderNotificationDetail(record);
assert.doesNotMatch(rowHtml + detailHtml, /<script>|<img/);
assert.match(rowHtml + detailHtml, /通知正文/);
assert.match(rowHtml + detailHtml, /未读/);
assert.doesNotMatch(rowHtml + detailHtml, /19100000000|>206000000000000000[1-4]</);
});
test('通知详情必须与请求的稳定通知编号一致', () => {
const record = notificationFixture();
assert.equal(NotificationPages.matchesNotification(record, '2060000000000000001'), true);
assert.equal(NotificationPages.matchesNotification(record, '2060000000000000009'), false);
assert.equal(NotificationPages.matchesNotification({}, '2060000000000000001'), false);
});
test('只有未读且正常的通知提供单条已读动作', () => {
assert.equal(NotificationPages.canMarkRead({ readStatus: '0', status: '0' }), true);
assert.equal(NotificationPages.canMarkRead({ readStatus: '1', status: '0' }), false);
assert.equal(NotificationPages.canMarkRead({ readStatus: '0', status: '1' }), false);
});
test('消息页提供筛选、业务详情和单条/全部已读,不显示原始 JSON', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-messages.html'), 'utf8');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.match(source, /src="public\/js\/notification-pages\.js"/);
assert.doesNotMatch(source, /src="public\/js\/pending-pages\.js"/);
assert.doesNotMatch(source, /data-feature-status="pending"|notification-raw|原始记录/);
assert.match(source, /data-notification-filter/);
assert.match(source, /data-notification-list/);
assert.match(source, /data-notification-detail/);
assert.match(source, /data-notification-unread-count/);
assert.match(source, /data-notification-read-all/);
assert.match(source, /src="public\/js\/notification-pages\.js"/);
assert.doesNotMatch(source, /src="public\/js\/pending-pages\.js"/);
});
+5 -2
View File
@@ -36,9 +36,8 @@ test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
test('pages do not load scripts for unavailable business APIs', () => {
const removedScripts = [
'album-pages.js', 'article-pages.js', 'ceremony-pages.js',
'feedback-pages.js', 'genealogy-pages.js',
'help-pages.js', 'join-apply-pages.js', 'member-admin-pages.js',
'help-pages.js', 'join-apply-pages.js',
'promotion-pages.js', 'vip-pages.js'
];
const retainedPages = fs.readdirSync(root).filter((entry) => entry.endsWith('.html'));
@@ -55,6 +54,10 @@ test('pages do not load scripts for unavailable business APIs', () => {
});
assert.equal(read('profile-generation.html').includes('src="public/js/generation-pages.js"'), true, 'profile-generation.html must load generation-pages.js');
assert.equal(read('profile-tree.html').includes('src="public/js/lineage-pages.js"'), true, 'profile-tree.html must load lineage-pages.js');
assert.equal(read('profile-family-admin.html').includes('src="public/js/member-admin-pages.js"'), true, 'profile-family-admin.html must load member-admin-pages.js');
assert.equal(read('profile-video.html').includes('src="public/js/video-pages.js"'), true, 'profile-video.html must load video-pages.js');
assert.equal(read('profile-gift.html').includes('src="public/js/ceremony-pages.js"'), true, 'profile-gift.html must load ceremony-pages.js');
assert.equal(read('profile-ceremony.html').includes('src="public/js/ceremony-admin-pages.js"'), true, 'profile-ceremony.html must load ceremony-admin-pages.js');
assert.equal(read('profile-relative.html').includes('src="public/js/relative-pages.js"'), true, 'profile-relative.html must load relative-pages.js');
assert.equal(read('profile-memo.html').includes('src="public/js/memo-pages.js"'), true, 'profile-memo.html must load memo-pages.js');
});
+19 -14
View File
@@ -7,24 +7,12 @@ const PageAvailability = require('../public/js/pending-pages.js');
const root = path.resolve(__dirname, '..');
const familyPendingPages = [
'profile-families.html',
'profile-create-family.html',
'profile-join-family.html',
'profile-join-review.html',
'profile-family-admin.html',
'profile-invite.html',
];
const contentPendingPages = [
'profile-content.html',
'profile-article.html',
'profile-article-edit.html',
'profile-album.html',
'profile-video.html',
'profile-gift.html',
'profile-gift-edit.html',
'profile-merit.html',
'profile-merit-edit.html',
'profile-feedback.html',
'profile-admin-permissions.html',
'profile-data-reminders.html'
@@ -65,16 +53,32 @@ test('待开发页面显式加载状态脚本', () => {
});
test('已接入的家谱业务页面保持真实功能状态', () => {
['profile-feed.html', 'profile-feed-edit.html', 'profile-generation.html', 'profile-tree.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-messages.html'].forEach((page) => {
['profile-families.html', 'profile-create-family.html', 'profile-family-admin.html', 'profile-content.html', 'profile-feed.html', 'profile-feed-edit.html', 'profile-feed-detail.html', 'profile-generation.html', 'profile-tree.html', 'profile-article.html', 'profile-article-edit.html', 'profile-album.html', 'profile-album-edit.html', 'profile-album-detail.html', 'profile-video.html', 'profile-video-edit.html', 'profile-gift.html', 'profile-gift-edit.html', 'profile-ceremony.html', 'profile-ceremony-detail.html', 'profile-growth.html', 'profile-growth-edit.html', 'profile-relative.html', 'profile-relative-edit.html', 'profile-memo.html', 'profile-memo-edit.html', 'profile-merit.html', 'profile-merit-edit.html', 'profile-messages.html'].forEach((page) => {
assert.doesNotMatch(read(page), /data-feature-status="pending"/, `${page} 不应标记为待开发`);
});
assert.match(read('profile-generation.html'), /src="public\/js\/generation-pages\.js"/);
assert.match(read('profile-families.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-create-family.html'), /src="public\/js\/genealogy-entry-pages\.js"/);
assert.match(read('profile-tree.html'), /src="public\/js\/lineage-pages\.js"/);
assert.match(read('profile-family-admin.html'), /src="public\/js\/member-admin-pages\.js"/);
assert.match(read('profile-article.html'), /src="public\/js\/article-pages\.js"/);
assert.match(read('profile-article-edit.html'), /src="public\/js\/article-pages\.js"/);
assert.match(read('profile-album.html'), /src="public\/js\/album-pages\.js"/);
assert.match(read('profile-album-edit.html'), /src="public\/js\/album-pages\.js"/);
assert.match(read('profile-album-detail.html'), /src="public\/js\/album-pages\.js"/);
assert.match(read('profile-video.html'), /src="public\/js\/video-pages\.js"/);
assert.match(read('profile-video-edit.html'), /src="public\/js\/video-pages\.js"/);
assert.match(read('profile-gift.html'), /src="public\/js\/ceremony-pages\.js"/);
assert.match(read('profile-gift-edit.html'), /src="public\/js\/ceremony-admin-pages\.js"/);
assert.match(read('profile-ceremony.html'), /src="public\/js\/ceremony-admin-pages\.js"/);
assert.match(read('profile-ceremony-detail.html'), /src="public\/js\/ceremony-admin-pages\.js"/);
assert.match(read('profile-growth.html'), /src="public\/js\/growth-pages\.js"/);
assert.match(read('profile-growth-edit.html'), /src="public\/js\/growth-pages\.js"/);
assert.match(read('profile-relative.html'), /src="public\/js\/relative-pages\.js"/);
assert.match(read('profile-relative-edit.html'), /src="public\/js\/relative-pages\.js"/);
assert.match(read('profile-memo.html'), /src="public\/js\/memo-pages\.js"/);
assert.match(read('profile-merit.html'), /src="public\/js\/merit-pages\.js"/);
assert.match(read('profile-merit-edit.html'), /src="public\/js\/merit-pages\.js"/);
assert.match(read('profile-messages.html'), /src="public\/js\/notification-pages\.js"/);
assert.match(read('profile-memo-edit.html'), /src="public\/js\/memo-pages\.js"/);
});
@@ -89,7 +93,7 @@ test('待开发的家谱管理页明确放行已接入的字辈入口并透传
});
test('待开发页面仅放行显式标记的可用功能入口', () => {
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-article.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feedback.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feed.html', 'data-feature-link': 'available' })), false);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: '#section' })), false);
});
@@ -99,6 +103,7 @@ test('内容入口明确保留家族圈动态跳转', () => {
assert.match(read('profile-content.html'), /href="profile-growth\.html" data-feature-link="available" data-genealogy-context-link/);
assert.match(read('profile-content.html'), /href="profile-relative\.html" data-feature-link="available" data-genealogy-context-link/);
assert.match(read('profile-content.html'), /href="profile-memo\.html" data-feature-link="available" data-genealogy-context-link/);
assert.match(read('profile-content.html'), /href="profile-merit\.html" data-feature-link="available" data-genealogy-context-link/);
});
test('家谱主页明确保留家族圈动态跳转', () => {
+188 -10
View File
@@ -1,23 +1,201 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const RelativePages = require('../public/js/relative-pages.js');
test('亲友往来页只从 URL 读取真实家谱编号', () => {
assert.equal(RelativePages.getCurrentGenealogyId('?genealogyId=900001001'), '900001001');
test('亲友记录页只从 URL 读取安全的真实家谱和记录编号', () => {
assert.equal(
RelativePages.getCurrentGenealogyId('?genealogyId=2060000000000000001&relativeId=2060000000000000002'),
'2060000000000000001'
);
assert.equal(
RelativePages.getCurrentRelativeId('?genealogyId=2060000000000000001&relativeId=2060000000000000002'),
'2060000000000000002'
);
assert.equal(RelativePages.getCurrentGenealogyId(''), '');
assert.equal(RelativePages.getCurrentRelativeId('?relativeId=unsafe'), '');
});
test('亲友往来写入只构造 Apifox RelativeRecordBody 字段', () => {
test('亲友记录写入只构造 YAML RelativeRecordBody 并转换后端时间格式', () => {
assert.deepEqual(RelativePages.buildRelativeRecordBody({
relativeName: ' 王叔 ', relationName: '长辈', eventName: '寿宴', eventTime: '2026-07-24T09:00:00+08:00',
giftAmount: '500.5', recordContent: ' 出席 ', mediaOssIds: '101,102', sortOrder: '3', status: 'enabled', memoTitle: '旧字段'
}), { relativeName: '王叔', relationName: '长辈', eventName: '寿宴', eventTime: '2026-07-24T09:00:00+08:00', giftAmount: 500.5, recordContent: '出席', mediaOssIds: '101,102', status: 'enabled', sortOrder: 3 });
relativeName: ' 王叔 ',
relationName: ' 长辈 ',
eventName: ' 寿宴 ',
eventTime: '2026-07-24T09:30',
giftAmount: '500.5',
recordContent: ' 出席 ',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: '3',
status: '0',
relativeId: 'should-not-send',
appUserId: 'should-not-send'
}), {
relativeName: '王叔',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: 500.5,
recordContent: '出席',
mediaOssIds: '2060000000000000003,2060000000000000004',
sortOrder: 3,
status: '0'
});
});
test('亲友往来校验必填姓名、金额、附件排序', () => {
test('亲友记录校验姓名、金额、时间、附件排序和可重读状态', () => {
assert.equal(RelativePages.validateRelativeRecordBody({ relativeName: '' }), '请填写亲友姓名');
assert.equal(RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', giftAmount: '一百' })), '礼金金额必须是数字');
assert.equal(RelativePages.validateRelativeRecordBody({ relativeName: '王叔', mediaOssIds: '101, 102' }), '附件 OSS ID 请使用英文逗号分隔的正整数');
assert.equal(RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', sortOrder: '1.5' })), '排序值必须是安全整数');
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', giftAmount: '一百' })),
'礼金金额必须是有限数字'
);
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({
relativeName: '王叔',
giftAmount: '9007199254740993.01'
})),
'礼金金额超出浏览器可安全提交的精度'
);
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', giftAmount: '500.50' })),
''
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', eventTime: '2026-07-24T09:30:00+08:00' }),
'事件时间格式无效'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', eventTime: '2026-99-24 09:30:00' }),
'事件时间格式无效'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', mediaOssIds: '101, 102' }),
'附件上传结果无效'
);
assert.equal(
RelativePages.validateRelativeRecordBody(RelativePages.buildRelativeRecordBody({ relativeName: '王叔', sortOrder: '1.5' })),
'排序值必须是安全整数'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', status: '1' }),
'当前 PC 无法重新读取停用记录,暂不开放停用'
);
assert.equal(
RelativePages.validateRelativeRecordBody({ relativeName: '王叔', giftAmount: -500.25, status: '0' }),
''
);
});
test('亲友记录响应使用 PC RelativeRecordVo 并保留 BigDecimal 字符串', () => {
assert.deepEqual(RelativePages.normalizeRelativeRecord({
relativeId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
relativeName: '王叔',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: '500.50',
recordContent: '出席',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '往来记录'
}), {
relativeId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
appUserId: '2060000000000000003',
appUserNickName: '叶子',
appUserPhone: '19100000000',
relativeName: '王叔',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: '500.50',
recordContent: '出席',
mediaOssIds: '2060000000000000004',
sortOrder: 1,
status: '0',
remark: '往来记录'
});
assert.equal(RelativePages.normalizeRelativeRecord({
relativeId: Number('2060000000000000001'),
genealogyId: '2',
relativeName: '王叔',
status: '0'
}), null);
});
test('亲友记录渲染业务字段但不暴露手机号、OSS ID 或未转义内容', () => {
const record = RelativePages.normalizeRelativeRecord({
relativeId: '1',
genealogyId: '2',
appUserId: '3',
appUserNickName: '叶子',
appUserPhone: '19100000000',
relativeName: '<script>alert(1)</script>',
relationName: '长辈',
eventName: '寿宴',
eventTime: '2026-07-24 09:30:00',
giftAmount: '500.50',
recordContent: '<img src=x onerror=alert(1)>出席',
mediaOssIds: '4,5',
sortOrder: 1,
status: '0'
});
const html = RelativePages.renderRelativeDetail(record);
assert.doesNotMatch(html, /<script>|<img/);
assert.match(html, /出席/);
assert.match(html, /500\.50/);
assert.match(html, /2 个附件/);
assert.doesNotMatch(html, /19100000000|>4<|>5</);
});
test('亲友记录写后重读必须返回同一条稳定记录', () => {
const detail = {
relativeId: '2060000000000000001',
genealogyId: '2060000000000000002',
relativeName: '王叔',
status: '0'
};
assert.equal(RelativePages.matchesSavedRecord(detail, '2060000000000000001'), true);
assert.equal(RelativePages.matchesSavedRecord(detail, '2060000000000000009'), false);
assert.equal(RelativePages.matchesSavedRecord({}, '2060000000000000001'), false);
});
test('亲友记录编辑器拒绝不可重读的停用记录', () => {
assert.equal(RelativePages.isEditableRecord({ status: '0' }), true);
assert.equal(RelativePages.isEditableRecord({ status: '1' }), false);
assert.equal(RelativePages.isEditableRecord(null), false);
});
test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或状态', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-relative.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-relative-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.match(listPage, /data-relative-list/);
assert.match(listPage, /data-relative-detail/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="eventTime" type="datetime-local"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.match(editPage, /data-relative-clear-media/);
assert.doesNotMatch(editPage, /name="(?:mediaOssIds|status)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/relative-pages\.js/);
assert.match(styles, /\[data-relative-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
+4 -1
View File
@@ -161,8 +161,11 @@ test('duplicate file submissions share one active upload', async () => {
test('business upload pages never expose editable OSS ID fields', () => {
const pages = [
'profile-data.html',
'profile-create-family.html',
'profile-article-edit.html',
'profile-album.html',
'profile-album-edit.html',
'profile-album-detail.html',
'profile-video-edit.html',
'profile-gift-edit.html',
'profile-growth-edit.html',
'profile-memo-edit.html',
+35
View File
@@ -0,0 +1,35 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const root = path.join(__dirname, '..');
function read(file) {
return fs.readFileSync(path.join(root, file), 'utf8');
}
test('内容发布页为全部家谱功能入口透传真实家谱上下文', () => {
const page = read('profile-content.html');
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js|最近内容加载中/);
['profile-article.html', 'profile-album.html', 'profile-video.html', 'profile-merit.html', 'profile-gift.html', 'profile-feed.html', 'profile-growth.html', 'profile-relative.html', 'profile-memo.html'].forEach((href) => {
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少家谱上下文透传`);
});
});
test('家谱主页公开谱文、相册、视频、功德和祭祀活动入口并透传上下文', () => {
const page = read('profile-family-home.html');
['profile-article.html', 'profile-album.html', 'profile-video.html', 'profile-merit.html', 'profile-ceremony.html'].forEach((href) => {
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少家谱主页入口`);
});
});
test('个人中心功能入口没有把家谱业务页当作无上下文普通链接', () => {
const page = read('profile.html');
['profile-article.html', 'profile-album.html', 'profile-video.html', 'profile-merit.html', 'profile-gift.html'].forEach((href) => {
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少个人中心上下文入口`);
});
});
+154
View File
@@ -0,0 +1,154 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const VideoPages = require('../public/js/video-pages.js');
test('视频页只从 URL 读取真实家谱和视频编号', () => {
assert.equal(VideoPages.getCurrentGenealogyId('?genealogyId=2060000000000000001&videoId=2060000000000000002'), '2060000000000000001');
assert.equal(VideoPages.getCurrentVideoId('?genealogyId=2060000000000000001&videoId=2060000000000000002'), '2060000000000000002');
assert.equal(VideoPages.getCurrentGenealogyId(''), '');
assert.equal(VideoPages.getCurrentVideoId('?videoId=unsafe'), '');
});
test('视频写入只构造 YAML VideoBody 字段并保持 OSS ID 为字符串', () => {
assert.deepEqual(VideoPages.buildVideoBody({
videoTitle: ' 家族活动记录 ',
videoDesc: ' 清明祭祖活动视频 ',
coverOssId: '2060000000000000001',
videoOssId: '2060000000000000002',
durationSeconds: '181',
sortOrder: '3',
status: '0',
publisherUserId: 'should-not-send',
viewCount: 99
}), {
videoTitle: '家族活动记录',
videoDesc: '清明祭祖活动视频',
coverOssId: '2060000000000000001',
videoOssId: '2060000000000000002',
durationSeconds: 181,
sortOrder: 3,
status: '0'
});
});
test('视频校验标题、上传文件、时长、排序和状态', () => {
assert.equal(VideoPages.validateVideoBody({ videoTitle: '', videoOssId: '1' }), '请填写视频标题');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题' }), '请先选择并上传视频文件');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '0' }), '视频文件上传结果无效');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', coverOssId: 'abc' }), '封面上传结果无效');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', durationSeconds: -1 }), '视频时长不能小于 0');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', sortOrder: undefined, invalidSortOrder: true }), '排序值必须是安全整数');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', status: '1' }), '当前 PC 无法重新读取停用视频,暂不开放停用');
assert.equal(VideoPages.validateVideoBody({ videoTitle: '标题', videoOssId: '1', status: '0' }), '');
});
test('视频响应使用 PC VideoVo 字段并拒绝不安全长 ID', () => {
assert.deepEqual(VideoPages.normalizeVideo({
videoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
videoTitle: '祭祖活动记录',
videoDesc: '活动影像',
coverOssId: '2060000000000000003',
videoOssId: '2060000000000000004',
durationSeconds: 180,
publisherUserId: '2060000000000000005',
publisherNickName: '叶子',
publisherPhone: '19100000000',
publishTime: '2026-07-29 10:00:00',
viewCount: 12,
sortOrder: 1,
status: '0',
remark: '归档'
}), {
videoId: '2060000000000000001',
genealogyId: '2060000000000000002',
genealogyNo: 'G20260729001',
genealogyName: '叶氏家谱',
surname: '叶',
videoTitle: '祭祖活动记录',
videoDesc: '活动影像',
coverOssId: '2060000000000000003',
videoOssId: '2060000000000000004',
durationSeconds: 180,
publisherUserId: '2060000000000000005',
publisherNickName: '叶子',
publisherPhone: '19100000000',
publishTime: '2026-07-29 10:00:00',
viewCount: 12,
sortOrder: 1,
status: '0',
remark: '归档'
});
assert.equal(VideoPages.normalizeVideo({
videoId: Number('2060000000000000001'),
genealogyId: '2',
videoTitle: '标题',
videoOssId: '3',
status: '0'
}), null);
});
test('视频渲染展示业务信息但不直接暴露 OSS ID 和发布人手机号', () => {
const video = VideoPages.normalizeVideo({
videoId: '1',
genealogyId: '2',
videoTitle: '<script>alert(1)</script>',
videoDesc: '活动影像',
coverOssId: '3',
videoOssId: '4',
durationSeconds: 180,
publisherUserId: '5',
publisherNickName: '叶子',
publisherPhone: '19100000000',
publishTime: '2026-07-29 10:00:00',
viewCount: 12,
sortOrder: 1,
status: '0'
});
const html = VideoPages.renderVideoDetail(video, true);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /活动影像/);
assert.match(html, /叶子/);
assert.match(html, /已上传/);
assert.doesNotMatch(html, />3</);
assert.doesNotMatch(html, />4</);
assert.doesNotMatch(html, /19100000000/);
});
test('视频文件元数据时长按秒取整且拒绝无效值', () => {
assert.equal(VideoPages.normalizeDurationSeconds(180.2), 181);
assert.equal(VideoPages.normalizeDurationSeconds(0), 0);
assert.equal(VideoPages.normalizeDurationSeconds(-1), undefined);
assert.equal(VideoPages.normalizeDurationSeconds(Infinity), undefined);
});
test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-video.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-video-edit.html'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'public', 'css', 'profile-module.css'), 'utf8');
assert.doesNotMatch(listPage, /data-feature-status="pending"/);
assert.match(listPage, /data-video-list/);
assert.match(listPage, /data-video-detail/);
assert.match(listPage, /data-video-management hidden/);
assert.match(listPage, /src="public\/js\/video-pages\.js"/);
assert.match(editPage, /data-video-form data-video-management hidden/);
assert.match(editPage, /name="videoTitle"/);
assert.match(editPage, /name="videoDesc"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="videoOssId" type="hidden"/);
assert.match(editPage, /name="durationSeconds"[^>]+readonly/);
assert.doesNotMatch(editPage, /name="(?:coverOssId|videoOssId)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/video-pages\.js/);
assert.match(styles, /\[data-video-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
});