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