修改功能
This commit is contained in:
@@ -6,6 +6,16 @@ const test = require('node:test');
|
||||
const modulePath = path.join(__dirname, '..', 'public', 'js', 'album-pages.js');
|
||||
const AlbumPages = fs.existsSync(modulePath) ? require(modulePath) : {};
|
||||
|
||||
function fileAccess(name, url) {
|
||||
return {
|
||||
fileId: '2060000000000000090',
|
||||
ossId: '2060000000000000091',
|
||||
fileName: name,
|
||||
mediaType: 'image/jpeg',
|
||||
accessUrl: url
|
||||
};
|
||||
}
|
||||
|
||||
function requireFunction(name) {
|
||||
assert.equal(typeof AlbumPages[name], 'function', `缺少 AlbumPages.${name}`);
|
||||
return AlbumPages[name];
|
||||
@@ -106,7 +116,7 @@ test('相册和照片响应使用完整 PC Vo 并拒绝不安全 ID', () => {
|
||||
genealogyId: '2060000000000000002',
|
||||
albumId: '2060000000000000001',
|
||||
albumName: '祠堂旧影',
|
||||
ossId: '2060000000000000005',
|
||||
photoFile: fileAccess('photo.jpg', 'https://files.example/photo.jpg'),
|
||||
photoTitle: '合影',
|
||||
photoDesc: '清明祭祖',
|
||||
photographer: '宗亲',
|
||||
@@ -119,7 +129,7 @@ test('相册和照片响应使用完整 PC Vo 并拒绝不安全 ID', () => {
|
||||
assert.equal(album.albumId, '2060000000000000001');
|
||||
assert.equal(album.photoCount, 2);
|
||||
assert.equal(photo.photoId, '2060000000000000004');
|
||||
assert.equal(photo.ossId, '2060000000000000005');
|
||||
assert.equal(photo.photoFile.accessUrl, 'https://files.example/photo.jpg');
|
||||
assert.equal(normalizeAlbum({
|
||||
albumId: Number('2060000000000000001'),
|
||||
genealogyId: '2',
|
||||
|
||||
@@ -56,7 +56,19 @@ test('API client exposes latest document-defined PC operations', () => {
|
||||
'ceremonyGifts', 'createCeremonyGift', 'deleteCeremony', 'deleteCeremonyGift',
|
||||
'replaceCeremonyInvitees', 'ceremonyInvitations', 'respondCeremonyInvitation', 'myCeremonyInvitations',
|
||||
'genealogyMembers', 'genealogyMemberOptions', 'updateGenealogyMember',
|
||||
'removeGenealogyMember', 'leaveGenealogy', 'transferGenealogyOwner'
|
||||
'removeGenealogyMember', 'leaveGenealogy', 'transferGenealogyOwner',
|
||||
'businessDictionary', 'saveGenealogyOrder', 'publicGenealogies', 'genealogyCompleteness',
|
||||
'archiveGenealogy', 'restoreGenealogy', 'updateGenealogy', 'genealogyProfileReminders',
|
||||
'issueGenealogyInvitation', 'previewGenealogyInvitation', 'redeemGenealogyInvitation',
|
||||
'myGenealogyInvitations', 'revokeGenealogyInvitation', 'genealogyDeletionCapability',
|
||||
'sendGenealogyDeletionCode', 'permanentlyDeleteGenealogy',
|
||||
'vipCapability', 'vipPaymentStatus', 'closeVipPayment',
|
||||
'earningSummary', 'earningLedger', 'earningWithdrawals',
|
||||
'createEarningWithdrawal', 'cancelEarningWithdrawal',
|
||||
'personDocuments', 'personDocument', 'createPersonDocument', 'updatePersonDocument',
|
||||
'deletePersonDocument', 'addPersonDocumentResource', 'deletePersonDocumentResource',
|
||||
'personDocumentResourceAccess', 'protectPersonDocument', 'unlockPersonDocument',
|
||||
'unprotectPersonDocument'
|
||||
].sort();
|
||||
|
||||
assert.deepEqual(Object.keys(client).sort(), allowed);
|
||||
@@ -753,7 +765,7 @@ test('feedback methods use the PC collection path and AppFeedbackBody whitelist'
|
||||
]);
|
||||
});
|
||||
|
||||
test('VIP methods use PC package and order paths with AppVipOrderBody whitelist', async () => {
|
||||
test('VIP methods use PC native-payment paths with PcVipOrderBody whitelist', async () => {
|
||||
const calls = [];
|
||||
const client = GenealogyApi.createClient({
|
||||
baseUrl: 'https://api.example.test',
|
||||
@@ -767,6 +779,7 @@ test('VIP methods use PC package and order paths with AppVipOrderBody whitelist'
|
||||
});
|
||||
|
||||
await client.vipPackages();
|
||||
await client.vipCapability();
|
||||
await client.createVipOrder({
|
||||
packageId: '2062179707935264769',
|
||||
genealogyId: '2062179707935264770',
|
||||
@@ -778,15 +791,57 @@ test('VIP methods use PC package and order paths with AppVipOrderBody whitelist'
|
||||
|
||||
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||
['get', '/genealogy/pc/vip/packages', undefined],
|
||||
['get', '/genealogy/pc/vip/capability', undefined],
|
||||
['post', '/genealogy/pc/vip/orders', {
|
||||
packageId: '2062179707935264769',
|
||||
genealogyId: '2062179707935264770',
|
||||
payType: 'wechat'
|
||||
genealogyId: '2062179707935264770'
|
||||
}],
|
||||
['get', '/genealogy/pc/vip/orders', undefined]
|
||||
]);
|
||||
});
|
||||
|
||||
test('PC closure methods use invitation, reminder, lifecycle, document and earning 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 = '2062179707935264701';
|
||||
|
||||
await client.genealogyProfileReminders(genealogyId);
|
||||
await client.issueGenealogyInvitation(genealogyId, { lineagePersonId: '2062179707935264702', ownerUserId: 'drop' });
|
||||
await client.genealogyDeletionCapability(genealogyId);
|
||||
await client.permanentlyDeleteGenealogy(genealogyId, { genealogyName: '叶氏家谱', smsCode: '123456', drop: true });
|
||||
await client.personDocuments(genealogyId, { lineagePersonId: '2062179707935264702', pageNum: 2 });
|
||||
await client.createPersonDocument(genealogyId, {
|
||||
lineagePersonId: '2062179707935264702', documentType: 'id_card', documentTitle: '身份证', drop: true
|
||||
});
|
||||
await client.createEarningWithdrawal({
|
||||
requestId: 'pc-request-1', amount: '88.50', payoutQrOssId: '2062179707935264703',
|
||||
payoutAccountName: '叶一', accountNo: 'drop'
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.map((config) => [config.method, config.url, config.params, config.data]), [
|
||||
['get', `/genealogy/pc/genealogies/${genealogyId}/profile-reminders`, undefined, undefined],
|
||||
['post', `/genealogy/pc/genealogies/${genealogyId}/invitations`, undefined, { lineagePersonId: '2062179707935264702' }],
|
||||
['get', `/genealogy/pc/genealogies/${genealogyId}/permanent-deletion/capability`, undefined, undefined],
|
||||
['post', `/genealogy/pc/genealogies/${genealogyId}/permanent-deletion`, undefined, { genealogyName: '叶氏家谱', smsCode: '123456' }],
|
||||
['get', `/genealogy/pc/genealogies/${genealogyId}/person-documents`, { lineagePersonId: '2062179707935264702' }, undefined],
|
||||
['post', `/genealogy/pc/genealogies/${genealogyId}/person-documents`, undefined, {
|
||||
lineagePersonId: '2062179707935264702', documentType: 'id_card', documentTitle: '身份证'
|
||||
}],
|
||||
['post', '/genealogy/pc/earnings/withdrawals', undefined, {
|
||||
requestId: 'pc-request-1', amount: '88.50', payoutQrOssId: '2062179707935264703', payoutAccountName: '叶一'
|
||||
}]
|
||||
]);
|
||||
});
|
||||
|
||||
test('notification methods use the current PC list, unread and read paths', async () => {
|
||||
const calls = [];
|
||||
const client = GenealogyApi.createClient({
|
||||
|
||||
@@ -131,6 +131,13 @@ test('推广区域将空、无权限与请求失败交给统一状态组件呈
|
||||
assert.match(source, /isForbidden\(error\) \? 'forbidden' : 'error'/);
|
||||
});
|
||||
|
||||
test('推广初始化只规范化一次,避免合法卡片被二次过滤', () => {
|
||||
const script = fs.readFileSync(path.resolve(__dirname, '../public/js/app-promotion-pages.js'), 'utf8');
|
||||
|
||||
assert.match(script, /list\.innerHTML = items\.map\(renderPromotionCard\)\.join\(''\)/);
|
||||
assert.doesNotMatch(script, /list\.innerHTML = renderPromotionList\(items\)/);
|
||||
});
|
||||
|
||||
test('应用下载页只通过页面标记和新推广模块接入,不暴露业务 ID 输入', () => {
|
||||
assert.match(appPage, /<body[^>]*data-promotion-page/);
|
||||
assert.match(appPage, /data-promotion-list/);
|
||||
|
||||
@@ -6,6 +6,16 @@ const test = require('node:test');
|
||||
const modulePath = path.join(__dirname, '..', 'public', 'js', 'article-pages.js');
|
||||
const ArticlePages = fs.existsSync(modulePath) ? require(modulePath) : {};
|
||||
|
||||
function coverFile() {
|
||||
return {
|
||||
fileId: '2060000000000000090',
|
||||
ossId: '2060000000000000091',
|
||||
fileName: 'cover.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
accessUrl: 'https://files.example/article-cover.jpg'
|
||||
};
|
||||
}
|
||||
|
||||
function requireFunction(name) {
|
||||
assert.equal(typeof ArticlePages[name], 'function', `缺少 ArticlePages.${name}`);
|
||||
return ArticlePages[name];
|
||||
@@ -90,7 +100,7 @@ test('谱文响应使用完整 ArticleVo 并拒绝不安全长 ID', () => {
|
||||
categoryCode: '',
|
||||
articleTitle: '家族源流',
|
||||
articleSummary: '先祖迁徙记录',
|
||||
coverOssId: '2060000000000000003',
|
||||
coverFile: coverFile(),
|
||||
articleContent: '<p>正文</p>',
|
||||
authorName: '宗亲',
|
||||
publishTime: '2026-07-29 10:00:00',
|
||||
@@ -101,7 +111,7 @@ test('谱文响应使用完整 ArticleVo 并拒绝不安全长 ID', () => {
|
||||
});
|
||||
|
||||
assert.equal(article.articleId, '2060000000000000001');
|
||||
assert.equal(article.coverOssId, '2060000000000000003');
|
||||
assert.equal(article.coverFile.accessUrl, 'https://files.example/article-cover.jpg');
|
||||
assert.equal(article.viewCount, 3);
|
||||
assert.equal(article.articleTitle, '家族源流');
|
||||
assert.equal(normalizeArticle({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -245,7 +245,7 @@ test('祭祀功能拆分列表、编辑和详情页面且不允许手填 ID', ()
|
||||
assert.match(editPage, /data-ceremony-form data-ceremony-editor hidden novalidate/);
|
||||
assert.match(editPage, /class="field-required"/);
|
||||
assert.match(editPage, /<div class="upload-status" data-ceremony-form-status/);
|
||||
assert.match(editPage, /Ceremony Editor/);
|
||||
assert.match(editPage, /活动编辑/);
|
||||
assert.doesNotMatch(editPage, /创建贺礼|贺礼标题|贺礼内容/);
|
||||
assert.match(editPage, /name="coverOssId" type="hidden"/);
|
||||
assert.match(editPage, /name="status" type="hidden" value="0"/);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
|
||||
const projectRoot = join(__dirname, '..');
|
||||
const destructiveModules = [
|
||||
'album-pages.js', 'article-pages.js', 'ceremony-admin-pages.js', 'earning-pages.js',
|
||||
'feed-pages.js', 'genealogy-lifecycle-pages.js', 'generation-pages.js', 'growth-pages.js',
|
||||
'join-pages.js', 'lineage-pages.js', 'memo-pages.js', 'member-admin-pages.js',
|
||||
'merit-pages.js', 'person-document-pages.js', 'relative-pages.js', 'security-pages.js',
|
||||
'video-pages.js', 'vip-pages.js'
|
||||
];
|
||||
|
||||
test('destructive business actions use the shared in-page confirmation dialog', () => {
|
||||
const profileCommon = readFileSync(join(projectRoot, 'public', 'js', 'profile-common.js'), 'utf8');
|
||||
|
||||
assert.match(profileCommon, /confirmAction:\s*confirmAction/);
|
||||
destructiveModules.forEach((fileName) => {
|
||||
const source = readFileSync(join(projectRoot, 'public', 'js', fileName), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /root\.confirm\s*\(/, `${fileName} should not open a native browser confirm`);
|
||||
assert.match(source, /ProfileUI\.confirmAction/, `${fileName} should use the shared confirmation dialog`);
|
||||
});
|
||||
});
|
||||
|
||||
test('password-protected document actions use the shared masked prompt dialog', () => {
|
||||
const profileCommon = readFileSync(join(projectRoot, 'public', 'js', 'profile-common.js'), 'utf8');
|
||||
const documentSource = readFileSync(join(projectRoot, 'public', 'js', 'person-document-pages.js'), 'utf8');
|
||||
|
||||
assert.match(profileCommon, /promptAction:\s*promptAction/);
|
||||
assert.doesNotMatch(documentSource, /root\.prompt\s*\(/);
|
||||
assert.match(documentSource, /ProfileUI\.promptAction/);
|
||||
});
|
||||
|
||||
test('legacy logout dialog stays out of the focus order until opened', () => {
|
||||
const page = readFileSync(join(projectRoot, 'profile.html'), 'utf8');
|
||||
const profileCommon = readFileSync(join(projectRoot, 'public', 'js', 'profile-common.js'), 'utf8');
|
||||
const styles = readFileSync(join(projectRoot, 'public', 'css', 'profile.css'), 'utf8');
|
||||
|
||||
assert.match(page, /id="logoutModal"[^>]*\shidden/);
|
||||
assert.match(profileCommon, /prop\('hidden', false\).*addClass\('show'\)/);
|
||||
assert.match(profileCommon, /removeClass\('show'\).*prop\('hidden', true\)/);
|
||||
assert.match(styles, /\.logout-modal\[hidden\]\s*\{\s*display:\s*none;/);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const FeedPages = require('../public/js/feed-pages.js');
|
||||
const LineagePages = require('../public/js/lineage-pages.js');
|
||||
const GrowthPages = require('../public/js/growth-pages.js');
|
||||
const RelativePages = require('../public/js/relative-pages.js');
|
||||
const MemoPages = require('../public/js/memo-pages.js');
|
||||
const MeritPages = require('../public/js/merit-pages.js');
|
||||
const CeremonyPages = require('../public/js/ceremony-admin-pages.js');
|
||||
const PromotionPages = require('../public/js/app-promotion-pages.js');
|
||||
|
||||
function imageFile(name) {
|
||||
return {
|
||||
fileId: '901',
|
||||
ossId: '902',
|
||||
fileName: name,
|
||||
mediaType: 'image/jpeg',
|
||||
accessUrl: `https://files.example/${name}`
|
||||
};
|
||||
}
|
||||
|
||||
test('动态和世系人物使用 mediaFiles/avatarFile', () => {
|
||||
const feed = FeedPages.normalizeFeed({ feedId: '1', feedContent: '祭祖留影', mediaFiles: [imageFile('feed.jpg')] });
|
||||
const person = LineagePages.normalizeLineagePerson({ personId: '2', name: '彭公', avatarFile: imageFile('person.jpg') });
|
||||
const tree = LineagePages.renderLineageTreeHtml([{ personId: '2', name: '彭公', avatarFile: imageFile('person.jpg') }]);
|
||||
|
||||
assert.equal(feed.mediaFiles[0].accessUrl, 'https://files.example/feed.jpg');
|
||||
assert.equal(person.avatarFile.accessUrl, 'https://files.example/person.jpg');
|
||||
assert.match(tree, /<img[^>]+person\.jpg/);
|
||||
assert.doesNotMatch(tree, /902/);
|
||||
});
|
||||
|
||||
test('成长、亲友、备忘和功德详情展示 mediaFiles', () => {
|
||||
const cases = [
|
||||
[GrowthPages, 'normalizeGrowthRecord', 'renderGrowthDetail', { recordId: '1', genealogyId: '2', recordTitle: '入学', status: '0' }],
|
||||
[RelativePages, 'normalizeRelativeRecord', 'renderRelativeDetail', { relativeId: '1', genealogyId: '2', relativeName: '张姨', status: '0' }],
|
||||
[MemoPages, 'normalizeMemo', 'renderMemoDetail', { memoId: '1', genealogyId: '2', memoTitle: '祭祖用品', completed: '0', status: '0' }],
|
||||
[MeritPages, 'normalizeMeritRecord', 'renderMeritDetail', { meritId: '1', genealogyId: '2', donorName: '彭先生', meritType: 'repair', meritTitle: '修缮祠堂', status: '0' }]
|
||||
];
|
||||
|
||||
cases.forEach(([module, normalizeName, renderName, fixture], index) => {
|
||||
const record = module[normalizeName](Object.assign({}, fixture, { mediaFiles: [imageFile(`record-${index}.jpg`)] }));
|
||||
const html = module[renderName](record);
|
||||
assert.match(html, new RegExp(`<img[^>]+record-${index}\\.jpg`));
|
||||
assert.doesNotMatch(html, /902/);
|
||||
});
|
||||
});
|
||||
|
||||
test('祭祀活动与应用推广展示 coverFile', () => {
|
||||
const ceremony = CeremonyPages.normalizeCeremony({
|
||||
ceremonyId: '1', genealogyId: '2', ceremonyType: '祭祖', ceremonyTitle: '清明祭祖', status: '0',
|
||||
coverFile: imageFile('ceremony.jpg')
|
||||
});
|
||||
const promotion = PromotionPages.normalizePromotion({
|
||||
promotionId: '3', promotionTitle: '家谱移动端', promotionDesc: '随时查看家谱', status: '0',
|
||||
coverFile: imageFile('promotion.jpg')
|
||||
});
|
||||
|
||||
assert.match(CeremonyPages.renderCeremonyDetail(ceremony), /<img[^>]+ceremony\.jpg/);
|
||||
assert.equal(promotion.coverFile.accessUrl, 'https://files.example/promotion.jpg');
|
||||
assert.match(PromotionPages.renderPromotionList([{
|
||||
promotionId: '3', promotionTitle: '家谱移动端', promotionDesc: '随时查看家谱', status: '0',
|
||||
coverFile: imageFile('promotion.jpg')
|
||||
}]), /<img[^>]+promotion\.jpg/);
|
||||
});
|
||||
@@ -160,18 +160,13 @@ test('家谱主页以 PC 工作台分组开放已接入功能且不制造旧 APP
|
||||
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.doesNotMatch(source, /四川武胜汤氏族/);
|
||||
assert.doesNotMatch(source, /href="profile-invite\.html"/);
|
||||
assert.doesNotMatch(source, /href="profile-document/);
|
||||
assert.match(source, /PC 暂未开放邀请/);
|
||||
assert.match(source, /class="module-card is-unavailable" aria-disabled="true">\s*<span class="icon">邀<\/span>/);
|
||||
assert.match(source, /重要证件/);
|
||||
assert.match(source, /当前 PC 接口未提供家谱证件的列表、上传或维护能力/);
|
||||
assert.match(source, /<h3>删除家谱<\/h3>/);
|
||||
assert.match(source, /当前 PC 家谱契约未提供删除接口,暂不能查看或执行删除操作。/);
|
||||
assert.doesNotMatch(source, /data-genealogy-delete|deleteGenealogy/);
|
||||
assert.match(source, /href="profile-invite\.html"[^>]*data-family-home-management[^>]*data-genealogy-context-link/);
|
||||
assert.match(source, /href="profile-documents\.html"[^>]*data-genealogy-context-link/);
|
||||
assert.match(source, /href="profile-family-settings\.html"[^>]*data-family-home-management[^>]*data-genealogy-context-link/);
|
||||
assert.doesNotMatch(source, /PC 暂未开放邀请|当前 PC 接口未提供家谱证件|当前 PC 家谱契约未提供删除接口/);
|
||||
assert.match(source, /亲友往来(贺礼簿)/);
|
||||
assert.match(source, /备忘录(家族恩人)/);
|
||||
assert.match(source, /当前 PC 不区分独立分类/);
|
||||
assert.match(source, /统一归入备忘录管理/);
|
||||
assert.match(source, /data-family-home-title/);
|
||||
assert.match(source, /data-family-home-overview/);
|
||||
assert.match(source, /data-family-home-member-count/);
|
||||
@@ -204,7 +199,10 @@ test('家谱主页以 PC 工作台分组开放已接入功能且不制造旧 APP
|
||||
'profile-relative.html',
|
||||
'profile-memo.html',
|
||||
'profile-family-admin.html',
|
||||
'profile-services.html'
|
||||
'profile-services.html#membership'
|
||||
,'profile-invite.html'
|
||||
,'profile-documents.html'
|
||||
,'profile-family-settings.html'
|
||||
].forEach((href) => {
|
||||
assert.match(
|
||||
source,
|
||||
|
||||
@@ -90,7 +90,6 @@ test('动态响应保留页面闭环所需的完整 FamilyFeedView 字段和字
|
||||
publisherStatus: '0',
|
||||
feedType: 'text',
|
||||
feedContent: '今日修谱。',
|
||||
mediaOssIds: '301,302',
|
||||
likedByMe: true,
|
||||
likeCount: 8,
|
||||
commentCount: 3,
|
||||
@@ -111,7 +110,6 @@ test('动态响应保留页面闭环所需的完整 FamilyFeedView 字段和字
|
||||
publisherStatus: '0',
|
||||
feedType: 'text',
|
||||
feedContent: '今日修谱。',
|
||||
mediaOssIds: '301,302',
|
||||
likedByMe: true,
|
||||
likeCount: 8,
|
||||
commentCount: 3,
|
||||
|
||||
@@ -18,6 +18,8 @@ function feedbackFixture(overrides) {
|
||||
appUserNickName: '叶子',
|
||||
appUserPhone: '19100000000',
|
||||
feedbackType: 'bug',
|
||||
feedbackTypeLabel: '问题反馈',
|
||||
feedbackTypeOptionState: 'ACTIVE',
|
||||
feedbackContent: '页面按钮无响应',
|
||||
contactInfo: '测试联系方式',
|
||||
handleStatus: '2',
|
||||
@@ -51,23 +53,27 @@ test('反馈请求只构造 AppFeedbackBody 并裁剪文本', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('反馈内容必填且类型只允许后端确认的四种值', () => {
|
||||
test('反馈内容必填且类型只允许后端当前启用的字典值', () => {
|
||||
const allowedTypes = ['advice', 'bug', 'complaint', 'other'];
|
||||
|
||||
assert.equal(FeedbackPages.validateFeedbackBody({ feedbackContent: '建议增加导出' }), '');
|
||||
assert.equal(FeedbackPages.validateFeedbackBody({
|
||||
feedbackType: 'advice',
|
||||
feedbackContent: '建议增加导出'
|
||||
}), '');
|
||||
}, allowedTypes), '');
|
||||
assert.match(FeedbackPages.validateFeedbackBody({ feedbackContent: '' }), /反馈内容/);
|
||||
assert.match(FeedbackPages.validateFeedbackBody({
|
||||
feedbackType: 'suggestion',
|
||||
feedbackContent: '建议增加导出'
|
||||
}), /反馈类型/);
|
||||
}, allowedTypes), /反馈类型/);
|
||||
});
|
||||
|
||||
test('FeedbackVo 只保留用户可见字段并保持长 ID 字符串', () => {
|
||||
assert.deepEqual(FeedbackPages.normalizeFeedback(feedbackFixture()), {
|
||||
feedbackId,
|
||||
feedbackType: 'bug',
|
||||
feedbackTypeLabel: '问题反馈',
|
||||
feedbackTypeOptionState: 'ACTIVE',
|
||||
feedbackContent: '页面按钮无响应',
|
||||
contactInfo: '测试联系方式',
|
||||
handleStatus: '2',
|
||||
@@ -90,6 +96,35 @@ test('FeedbackVo 只保留用户可见字段并保持长 ID 字符串', () => {
|
||||
})), null);
|
||||
});
|
||||
|
||||
test('未知历史反馈类型使用后端标签展示,不影响整批历史记录', () => {
|
||||
const legacyFeedback = feedbackFixture({
|
||||
feedbackType: '功能问题',
|
||||
feedbackTypeLabel: '功能问题',
|
||||
feedbackTypeOptionState: 'UNKNOWN'
|
||||
});
|
||||
const normalized = FeedbackPages.normalizeFeedbackList([
|
||||
feedbackFixture(),
|
||||
legacyFeedback
|
||||
]);
|
||||
|
||||
assert.equal(normalized.length, 2);
|
||||
assert.equal(normalized[1].feedbackType, '功能问题');
|
||||
assert.equal(normalized[1].feedbackTypeLabel, '功能问题');
|
||||
assert.equal(normalized[1].feedbackTypeOptionState, 'UNKNOWN');
|
||||
assert.match(FeedbackPages.renderFeedbackList([legacyFeedback]), /功能问题/);
|
||||
});
|
||||
|
||||
test('反馈类型选项只接受后端当前启用的业务字典项', () => {
|
||||
assert.deepEqual(FeedbackPages.normalizeFeedbackTypeOptions([
|
||||
{ value: 'advice', label: '建议', enabled: true },
|
||||
{ value: 'bug', label: '问题反馈', enabled: true },
|
||||
{ value: 'old', label: '旧类型', enabled: false }
|
||||
]), [
|
||||
{ value: 'advice', label: '建议' },
|
||||
{ value: 'bug', label: '问题反馈' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('反馈数组包含非法记录时整批拒绝', () => {
|
||||
assert.equal(FeedbackPages.normalizeFeedbackList([feedbackFixture()]).length, 1);
|
||||
assert.deepEqual(FeedbackPages.normalizeFeedbackList([
|
||||
@@ -166,6 +201,7 @@ test('我的工单和详情页读取同一反馈集合且不制造处理动作',
|
||||
test('反馈运行时提交后重读同一 ID,详情不存在时不回退', () => {
|
||||
const source = read('public/js/feedback-pages.js');
|
||||
|
||||
assert.match(source, /api\.businessDictionary\('gen_feedback_type'\)/);
|
||||
assert.match(source, /await api\.submitFeedback\(/);
|
||||
assert.match(source, /await api\.myFeedback\(\)/);
|
||||
assert.match(source, /提交后无法读取同一反馈记录/);
|
||||
|
||||
@@ -188,15 +188,17 @@ test('我的家谱页是可用的真实列表入口', () => {
|
||||
assert.match(script, /result = await api\.genealogyQuota\(\);/);
|
||||
});
|
||||
|
||||
test('家谱排序在未声明 PC 读取和保存契约时保持明确不可操作状态', () => {
|
||||
test('家谱排序使用我的家谱响应并保存稳定 ID 顺序', () => {
|
||||
const source = read('profile-families.html');
|
||||
const apiClient = read('utils/ApiClient.js');
|
||||
const verification = read('docs/验证20%记录.md');
|
||||
|
||||
assert.match(source, /class="module-card is-unavailable" aria-disabled="true">\s*<span class="icon">序<\/span>\s*<h3>家谱排序<\/h3>/);
|
||||
assert.match(source, /当前 PC 家谱契约未提供自定义排序的读取或保存接口/);
|
||||
assert.doesNotMatch(apiClient, /sortGenealogy|genealogySort/);
|
||||
assert.match(verification, /家谱邀请、家谱删除、家谱排序、分享奖励/);
|
||||
assert.match(source, /data-genealogy-sort-select/);
|
||||
assert.match(source, /data-genealogy-sort-up/);
|
||||
assert.match(source, /data-genealogy-sort-down/);
|
||||
assert.match(source, /data-genealogy-sort-save/);
|
||||
assert.match(apiClient, /saveGenealogyOrder/);
|
||||
assert.match(verification, /分享奖励、推荐偏好仍保持不可操作/);
|
||||
});
|
||||
|
||||
test('创建家谱页开放真实 PC 表单且不允许手填业务 ID', () => {
|
||||
@@ -238,7 +240,7 @@ test('公共创建入口复用真实 PC 创建表单,不保留无效的静态
|
||||
assert.match(source, /name="(?:ancestralHall|originPlace|addressDetail|intro|visibility|joinMode)"/);
|
||||
assert.match(source, /data-region-picker-status role="status" aria-live="polite"/);
|
||||
assert.match(source, /id="publicJoinMode"[^>]*aria-describedby="public-invite-mode-note"/);
|
||||
assert.match(source, /当前 PC 未提供邀请码生成、发送或管理接口/);
|
||||
assert.match(source, /创建完成后,可在家谱首页的“邀请家人”中生成一次性邀请链接/);
|
||||
assert.match(source, /data-genealogy-create-submit/);
|
||||
assert.match(source, /public\/js\/profile-common\.js/);
|
||||
assert.match(source, /public\/js\/region-pages\.js/);
|
||||
@@ -253,7 +255,7 @@ test('创建页保留合法邀请模式,但不把缺失的邀请码管理伪
|
||||
|
||||
[profileCreate, publicCreate].forEach((source) => {
|
||||
assert.match(source, /<option value="2">邀请码加入<\/option>/);
|
||||
assert.match(source, /当前 PC 未提供邀请码生成、发送或管理接口/);
|
||||
assert.match(source, /创建完成后,可在家谱首页的“邀请家人”中生成一次性邀请链接/);
|
||||
});
|
||||
assert.match(profileCreate, /id="joinMode"[^>]*aria-describedby="family-invite-mode-note"/);
|
||||
assert.match(publicCreate, /id="publicJoinMode"[^>]*aria-describedby="public-invite-mode-note"/);
|
||||
@@ -333,8 +335,9 @@ test('小屏导航提供统一入口,并且所有家谱内链接继续透传
|
||||
|
||||
assert.match(common, /data-profile-mobile-menu-toggle/);
|
||||
assert.match(common, /data-profile-mobile-menu/);
|
||||
assert.match(common, /profile-family-home\.html" data-genealogy-context-link/);
|
||||
assert.match(common, /profile-family-admin\.html" data-genealogy-context-link/);
|
||||
assert.match(common, /href: 'profile-family-home\.html', context: true/);
|
||||
assert.match(common, /href: 'profile-family-admin\.html', context: true/);
|
||||
assert.match(common, /renderWorkspaceNavigation\(navigation, currentPage\)/);
|
||||
assert.match(css, /\.profile-mobile-menu\.is-open/);
|
||||
assert.match(css, /\.profile-current-genealogy/);
|
||||
});
|
||||
|
||||
@@ -110,7 +110,6 @@ test('成长记录响应使用 PC GrowthRecordVo 并拒绝不安全长 ID', () =
|
||||
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: '成长节点'
|
||||
@@ -131,7 +130,6 @@ test('成长记录响应使用 PC GrowthRecordVo 并拒绝不安全长 ID', () =
|
||||
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: '成长节点'
|
||||
@@ -157,7 +155,6 @@ test('成长记录渲染业务字段但不暴露手机号、OSS ID 或未转义
|
||||
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'
|
||||
});
|
||||
@@ -166,7 +163,7 @@ test('成长记录渲染业务字段但不暴露手机号、OSS ID 或未转义
|
||||
assert.doesNotMatch(html, /<script>|<img/);
|
||||
assert.match(html, /第一天上学/);
|
||||
assert.match(html, /叶小明/);
|
||||
assert.match(html, /2 个附件/);
|
||||
assert.match(html, /未上传/);
|
||||
assert.doesNotMatch(html, /19100000000|>5<|>6</);
|
||||
});
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ test('世系人物响应保留完整详情字段并拒绝不安全的嵌套 ID',
|
||||
personId: '2060000000000000000',
|
||||
genealogyId: '2060000000000000001',
|
||||
appUserId: '2060000000000000002',
|
||||
avatarOssId: '2060000000000000003',
|
||||
fatherId: '2060000000000000004',
|
||||
motherId: '2060000000000000005',
|
||||
genealogyName: '李氏家谱',
|
||||
@@ -186,7 +185,6 @@ test('世系人物响应保留完整详情字段并拒绝不安全的嵌套 ID',
|
||||
genealogyId: '2060000000000000001',
|
||||
fatherId: '2060000000000000004',
|
||||
motherId: '2060000000000000005',
|
||||
avatarOssId: '2060000000000000003',
|
||||
generation: 3,
|
||||
sortOrder: 1
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
|
||||
const MediaDisplay = require('../public/js/media-display');
|
||||
|
||||
const imageFile = {
|
||||
fileId: '2060000000000000001',
|
||||
ossId: 'private-oss-key',
|
||||
fileName: 'family-cover.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
fileSize: 1024,
|
||||
accessUrl: 'https://files.example/family-cover.jpg?signature=a&expires=1',
|
||||
expiresAt: '2026-08-28T03:00:00+08:00'
|
||||
};
|
||||
|
||||
test('attachment galleries keep a human-sized thumbnail grid and preserve the whole image', () => {
|
||||
const css = readFileSync(require.resolve('../public/css/public.css'), 'utf8');
|
||||
const moduleCss = readFileSync(require.resolve('../public/css/profile-module.css'), 'utf8');
|
||||
|
||||
assert.match(css, /\.media-gallery\s*\{[^}]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(min\(200px,\s*100%\),\s*280px\)\)/s);
|
||||
assert.match(css, /\.media-gallery\s+\.media-frame\s+img\s*\{[^}]*object-fit:\s*contain/s);
|
||||
assert.match(moduleCss, /:is\(\.article-cover-media,\s*\.album-cover-media,\s*\.ceremony-cover-media\)\s+img\s*\{[^}]*object-fit:\s*contain/s);
|
||||
assert.match(moduleCss, /\.video-detail-media\s+video\s*\{[^}]*object-fit:\s*contain/s);
|
||||
});
|
||||
|
||||
test('normalizes authorized file access without exposing storage identifiers', () => {
|
||||
const normalized = MediaDisplay.normalizeFileAccess(imageFile);
|
||||
|
||||
assert.deepEqual(normalized, {
|
||||
fileId: '2060000000000000001',
|
||||
fileName: 'family-cover.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
fileSize: 1024,
|
||||
accessUrl: imageFile.accessUrl,
|
||||
expiresAt: imageFile.expiresAt
|
||||
});
|
||||
assert.equal('ossId' in normalized, false);
|
||||
});
|
||||
|
||||
test('rejects unsafe and incomplete media URLs', () => {
|
||||
assert.equal(MediaDisplay.normalizeFileAccess({ accessUrl: 'javascript:alert(1)' }), null);
|
||||
assert.equal(MediaDisplay.normalizeFileAccess({ accessUrl: '/resource/oss/1' }), null);
|
||||
assert.equal(MediaDisplay.normalizeFileAccess({ fileId: '1' }), null);
|
||||
});
|
||||
|
||||
test('renders an accessible lazy image and escapes signed URLs', () => {
|
||||
const html = MediaDisplay.renderImage(imageFile, {
|
||||
alt: '彭氏家谱封面',
|
||||
className: 'family-cover',
|
||||
caption: '彭氏家谱'
|
||||
});
|
||||
|
||||
assert.match(html, /<figure class="media-frame family-cover"/);
|
||||
assert.match(html, /<img[^>]+alt="彭氏家谱封面"/);
|
||||
assert.match(html, /loading="lazy"/);
|
||||
assert.match(html, /signature=a&expires=1/);
|
||||
assert.match(html, /<figcaption>彭氏家谱<\/figcaption>/);
|
||||
assert.doesNotMatch(html, /private-oss-key|ossId/);
|
||||
});
|
||||
|
||||
test('renders avatar initials when no authorized image exists', () => {
|
||||
assert.equal(
|
||||
MediaDisplay.renderAvatar(null, { name: '彭小雨' }),
|
||||
'<span class="media-avatar" aria-label="彭小雨的头像">彭</span>'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders mixed media gallery with image, video and downloadable files', () => {
|
||||
const gallery = MediaDisplay.renderGallery([
|
||||
imageFile,
|
||||
{ ...imageFile, fileId: '2', fileName: 'memory.mp4', mediaType: 'video/mp4', accessUrl: 'https://files.example/memory.mp4' },
|
||||
{ ...imageFile, fileId: '3', fileName: 'record.pdf', mediaType: 'application/pdf', accessUrl: 'https://files.example/record.pdf' }
|
||||
], { label: '成长日志附件' });
|
||||
|
||||
assert.match(gallery, /aria-label="成长日志附件"/);
|
||||
assert.match(gallery, /<img/);
|
||||
assert.match(gallery, /<video[^>]+controls/);
|
||||
assert.match(gallery, /下载 record\.pdf/);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const ProfilePages = require('../public/js/profile-pages.js');
|
||||
const FamilyHomePages = require('../public/js/family-home-pages.js');
|
||||
const AlbumPages = require('../public/js/album-pages.js');
|
||||
const VideoPages = require('../public/js/video-pages.js');
|
||||
const ArticlePages = require('../public/js/article-pages.js');
|
||||
|
||||
const genealogyId = '2062179707935264769';
|
||||
|
||||
function fileAccess(name, mediaType, url) {
|
||||
return {
|
||||
fileId: '2062179707935264770',
|
||||
ossId: '2062179707935264771',
|
||||
fileName: name,
|
||||
mediaType,
|
||||
fileSize: 1024,
|
||||
accessUrl: url,
|
||||
expiresAt: '2026-08-28T12:00:00+08:00'
|
||||
};
|
||||
}
|
||||
|
||||
test('个人资料使用 avatarFile 展示头像且不暴露 OSS ID', () => {
|
||||
const view = ProfilePages.buildProfileView({
|
||||
nickName: '小雨',
|
||||
avatarFile: fileAccess('avatar.jpg', 'image/jpeg', 'https://files.example/avatar.jpg?token=1')
|
||||
});
|
||||
|
||||
assert.equal(view.avatarFile.accessUrl, 'https://files.example/avatar.jpg?token=1');
|
||||
assert.equal(view.avatarStatus, '已设置');
|
||||
assert.equal('ossId' in view.avatarFile, false);
|
||||
});
|
||||
|
||||
test('家谱概览使用 coverFile,并渲染真实封面', () => {
|
||||
const overview = FamilyHomePages.normalizeFamilyOverview({
|
||||
genealogyId,
|
||||
genealogyName: '彭氏家谱',
|
||||
status: '0',
|
||||
memberCount: 12,
|
||||
personCount: 36,
|
||||
coverFile: fileAccess('cover.jpg', 'image/jpeg', 'https://files.example/family-cover.jpg')
|
||||
}, genealogyId);
|
||||
const html = FamilyHomePages.renderFamilyOverview(overview);
|
||||
|
||||
assert.equal(overview.coverFile.accessUrl, 'https://files.example/family-cover.jpg');
|
||||
assert.match(html, /<img[^>]+family-cover\.jpg/);
|
||||
assert.doesNotMatch(html, /2062179707935264771/);
|
||||
});
|
||||
|
||||
test('相册和照片使用 coverFile/photoFile 展示图片', () => {
|
||||
const album = AlbumPages.normalizeAlbum({
|
||||
albumId: '1', genealogyId: '2', albumName: '清明祭祖', status: '0',
|
||||
coverFile: fileAccess('album.jpg', 'image/jpeg', 'https://files.example/album.jpg')
|
||||
});
|
||||
const photo = AlbumPages.normalizeAlbumPhoto({
|
||||
photoId: '3', genealogyId: '2', albumId: '1', status: '0',
|
||||
photoFile: fileAccess('group.jpg', 'image/jpeg', 'https://files.example/group.jpg')
|
||||
});
|
||||
const html = AlbumPages.renderAlbumDetail(album) + AlbumPages.renderPhotoList([photo]);
|
||||
|
||||
assert.match(html, /<img[^>]+album\.jpg/);
|
||||
assert.match(html, /<img[^>]+group\.jpg/);
|
||||
assert.doesNotMatch(html, /2062179707935264771/);
|
||||
});
|
||||
|
||||
test('视频使用 videoFile 播放并使用 coverFile 作为封面', () => {
|
||||
const video = VideoPages.normalizeVideo({
|
||||
videoId: '1', genealogyId: '2', videoTitle: '祭祖影像', status: '0',
|
||||
videoFile: fileAccess('ceremony.mp4', 'video/mp4', 'https://files.example/ceremony.mp4'),
|
||||
coverFile: fileAccess('video-cover.jpg', 'image/jpeg', 'https://files.example/video-cover.jpg')
|
||||
});
|
||||
const html = VideoPages.renderVideoDetail(video, false);
|
||||
|
||||
assert.match(html, /<video[^>]+ceremony\.mp4/);
|
||||
assert.match(html, /poster="https:\/\/files\.example\/video-cover\.jpg/);
|
||||
assert.doesNotMatch(html, /2062179707935264771/);
|
||||
});
|
||||
|
||||
test('谱文使用 coverFile 展示封面', () => {
|
||||
const article = ArticlePages.normalizeArticle({
|
||||
articleId: '1', genealogyId: '2', articleTitle: '迁徙记', articleContent: '正文', status: '0',
|
||||
coverFile: fileAccess('article.jpg', 'image/jpeg', 'https://files.example/article.jpg')
|
||||
});
|
||||
const html = ArticlePages.renderArticleDetail(article);
|
||||
|
||||
assert.match(html, /<img[^>]+article\.jpg/);
|
||||
assert.doesNotMatch(html, /2062179707935264771/);
|
||||
});
|
||||
@@ -180,7 +180,7 @@ test('家族管理页开放成员维护、退出和谱主转移且不允许手
|
||||
assert.match(page, /data-owner-transfer-form novalidate/);
|
||||
assert.match(page, /name="targetMemberId"[^>]+required/);
|
||||
assert.match(page, /data-member-form-status/);
|
||||
assert.match(page, /空值清除语义/);
|
||||
assert.match(page, /留空表示保持原信息不变;只填写本次需要修改的内容/);
|
||||
assert.match(page, /data-owner-transfer-status/);
|
||||
assert.doesNotMatch(page, /name="(?:memberId|appUserId)"|type="(?:text|number)"[^>]+name="(?:lineagePersonId|targetMemberId)"/);
|
||||
assert.doesNotMatch(page, /href="profile-data\.html"[^>]*>\s*添加成员/);
|
||||
|
||||
@@ -84,7 +84,6 @@ test('备忘录响应使用 PC MemoVo 并拒绝不安全的数字长整型', ()
|
||||
memoContent: '准备供品',
|
||||
remindTime: '2026-07-24 09:30:00',
|
||||
completed: '1',
|
||||
mediaOssIds: '2060000000000000004',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: '年度事项'
|
||||
@@ -101,7 +100,6 @@ test('备忘录响应使用 PC MemoVo 并拒绝不安全的数字长整型', ()
|
||||
memoContent: '准备供品',
|
||||
remindTime: '2026-07-24 09:30:00',
|
||||
completed: '1',
|
||||
mediaOssIds: '2060000000000000004',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: '年度事项'
|
||||
@@ -126,7 +124,6 @@ test('备忘录渲染业务字段但不暴露手机号、OSS ID 或未转义内
|
||||
memoContent: '<img src=x onerror=alert(1)>准备供品',
|
||||
remindTime: '2026-07-24 09:30:00',
|
||||
completed: '1',
|
||||
mediaOssIds: '4,5',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: '年度事项'
|
||||
@@ -136,7 +133,7 @@ test('备忘录渲染业务字段但不暴露手机号、OSS ID 或未转义内
|
||||
assert.doesNotMatch(html, /<script>|<img/);
|
||||
assert.match(html, /准备供品/);
|
||||
assert.match(html, /已完成/);
|
||||
assert.match(html, /2 个附件/);
|
||||
assert.match(html, /未上传/);
|
||||
assert.doesNotMatch(html, /19100000000|>4<|>5</);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('内容发布页在已选择家谱时不再显示“请先选择家谱”的静态误导提示', () => {
|
||||
const source = read('profile-content.html');
|
||||
|
||||
assert.doesNotMatch(source, /请先选择家谱,再进入对应模块/);
|
||||
});
|
||||
|
||||
test('会员入口直接定位到会员服务区,不改变通用服务中心语义', () => {
|
||||
const familyHome = read('profile-family-home.html');
|
||||
const profile = read('profile.html');
|
||||
const services = read('profile-services.html');
|
||||
|
||||
assert.match(familyHome, /<h3>会员与订单<\/h3>/);
|
||||
assert.match(familyHome, /href="profile-services\.html#membership"[^>]*data-genealogy-context-link/);
|
||||
assert.match(profile, /href="profile-services\.html#membership"><strong>会员服务<\/strong>/);
|
||||
assert.match(services, /<title>帮助与服务 - 个人中心 - 代代相传<\/title>/);
|
||||
assert.match(services, /<h1>帮助与服务<\/h1>/);
|
||||
assert.match(services, /<section class="module-panel" id="membership" tabindex="-1">\s*<h2>会员服务<\/h2>/);
|
||||
});
|
||||
|
||||
test('家谱工作台不混用英文模块眉题', () => {
|
||||
assert.doesNotMatch(read('profile-tree.html'), />Tree Mode</);
|
||||
assert.doesNotMatch(read('profile-family-admin.html'), />Family Admin</);
|
||||
});
|
||||
|
||||
test('世系维护先选择成员再展示关系操作,且字辈入口名称与目标一致', () => {
|
||||
const source = read('profile-tree.html');
|
||||
const script = read('public/js/lineage-pages.js');
|
||||
const selectorIndex = source.indexOf('data-lineage-person-options');
|
||||
const actionIndex = source.indexOf('data-lineage-relation="parents"');
|
||||
|
||||
assert.ok(selectorIndex > 0 && selectorIndex < actionIndex, '成员选择器应位于关系操作之前');
|
||||
assert.match(source, /href="profile-generation\.html"[^>]*data-genealogy-context-link[^>]*>\s*管理字辈\s*<\/a/);
|
||||
assert.doesNotMatch(source, />\s*管理世代\s*<\/a/);
|
||||
assert.match(script, /queryAll\('\[data-lineage-relation\]'\)[\s\S]*?control\.disabled = writeDisabled \|\| !selectedPersonId/);
|
||||
});
|
||||
|
||||
test('个人工作台使用紧凑首屏和高对比度导航', () => {
|
||||
const publicCss = read('public/css/public.css');
|
||||
const moduleCss = read('public/css/profile-module.css');
|
||||
|
||||
assert.match(publicCss, /\.nav-links\s*\{[^}]*color:\s*#fffdf8;/);
|
||||
assert.match(publicCss, /\.nav-actions\s*\{[^}]*color:\s*#fffdf8;/);
|
||||
assert.match(publicCss, /\.nav-links a,\s*\.nav-actions a\s*\{[^}]*color:\s*inherit;/);
|
||||
assert.match(moduleCss, /\.page-profile-module \.nav\s*\{\s*height:\s*72px;/);
|
||||
assert.match(moduleCss, /\.page-profile-module \.section\s*\{\s*padding:\s*var\(--space-7\) 0;/);
|
||||
assert.match(moduleCss, /\.module-hero\s*\{[^}]*height:\s*168px;[^}]*min-height:\s*168px;/);
|
||||
});
|
||||
|
||||
test('手机菜单按 Escape 关闭后把焦点还给菜单按钮', () => {
|
||||
const source = read('public/js/profile-common.js');
|
||||
|
||||
assert.match(source, /event\.key === 'Escape'[\s\S]*?setMobileMenuOpen\(false\);[\s\S]*?\$\('\[data-profile-mobile-menu-toggle\]'\)\.first\(\)\.trigger\('focus'\)/);
|
||||
});
|
||||
|
||||
test('公共页面页脚的栏目链接都有真实跳转地址', () => {
|
||||
[
|
||||
'index.html',
|
||||
'create-genealogy.html',
|
||||
'about.html',
|
||||
'app.html',
|
||||
'help.html',
|
||||
'genealogy.html',
|
||||
'plaza.html',
|
||||
'surname.html',
|
||||
'culture.html',
|
||||
'promo-album.html',
|
||||
'promo-video.html'
|
||||
].forEach((page) => {
|
||||
const source = read(page);
|
||||
const footerMatch = source.match(/<footer[\s\S]*?<\/footer>/);
|
||||
|
||||
assert.ok(footerMatch, `${page} 缺少页脚`);
|
||||
assert.doesNotMatch(
|
||||
footerMatch[0],
|
||||
/<a(?:\s+class="[^"]*")?>\s*(?:数字家谱|家谱广场|应用下载|帮助中心)\s*<\/a>/,
|
||||
`${page} 页脚存在无 href 的伪链接`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('公共导航提供统一的登录态切换脚本', () => {
|
||||
const script = read('public/js/page-effects.js');
|
||||
|
||||
assert.match(script, /genealogy_auth_token/);
|
||||
assert.match(script, /profile\.html/);
|
||||
assert.match(script, /个人中心/);
|
||||
assert.match(script, /data-site-menu-toggle/);
|
||||
assert.match(script, /data-site-mobile-menu/);
|
||||
});
|
||||
|
||||
test('用户页面不暴露占位链接、假联系方式和开发阶段说明', () => {
|
||||
const htmlFiles = fs.readdirSync(path.resolve(__dirname, '..'))
|
||||
.filter((file) => file.endsWith('.html'));
|
||||
|
||||
htmlFiles.forEach((file) => {
|
||||
const source = read(file);
|
||||
const visibleSource = source.replace(/<!--[\s\S]*?-->/g, '');
|
||||
|
||||
assert.doesNotMatch(source, /https?:\/\/example\.com/i, `${file} 仍包含占位外链`);
|
||||
assert.doesNotMatch(source, /400-000-0000|service@example\.com/i, `${file} 仍包含假联系方式`);
|
||||
assert.doesNotMatch(source, /实际项目可替换|后续可以接|前台先展示/, `${file} 仍包含开发阶段说明`);
|
||||
assert.doesNotMatch(source, /承接 APP|整合 APP|对应 PC|当前 PC|PC 的“|真实接口|接口为准/, `${file} 仍包含实现口吻`);
|
||||
assert.doesNotMatch(visibleSource, /YAML|OpenAPI|DTO|OSS ID|PC 反馈接口|PC 契约|资料接口/, `${file} 仍向用户暴露开发术语`);
|
||||
});
|
||||
});
|
||||
|
||||
test('页面眉题统一使用中文产品语言', () => {
|
||||
const htmlFiles = fs.readdirSync(path.resolve(__dirname, '..'))
|
||||
.filter((file) => file.endsWith('.html'));
|
||||
|
||||
htmlFiles.forEach((file) => {
|
||||
const source = read(file);
|
||||
const kickers = [...source.matchAll(/<div class="[^"]*(?:eyebrow|kicker)[^"]*">\s*([^<]+?)\s*<\/div>/g)]
|
||||
.map((match) => match[1].trim());
|
||||
|
||||
kickers.forEach((kicker) => {
|
||||
assert.doesNotMatch(kicker, /^[A-Za-z][A-Za-z ]*$/, `${file} 的眉题仍是纯英文:${kicker}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('所有静态站内链接都指向存在的页面和锚点', () => {
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const htmlFiles = fs.readdirSync(projectRoot).filter((file) => file.endsWith('.html'));
|
||||
|
||||
htmlFiles.forEach((file) => {
|
||||
const source = read(file);
|
||||
const hrefs = [...source.matchAll(/\shref="([^"]+)"/g)].map((match) => match[1]);
|
||||
|
||||
hrefs.forEach((href) => {
|
||||
if (/^(?:https?:|mailto:|tel:|javascript:)/i.test(href)) return;
|
||||
assert.notEqual(href, '#', `${file} 存在不能导航的空锚点`);
|
||||
|
||||
const targetUrl = new URL(href, `http://jiapu.local/${file}`);
|
||||
const targetName = decodeURIComponent(targetUrl.pathname.replace(/^\//, '')) || 'index.html';
|
||||
const targetPath = path.resolve(projectRoot, targetName);
|
||||
assert.ok(targetPath.startsWith(projectRoot + path.sep), `${file} 链接越出项目目录:${href}`);
|
||||
assert.ok(fs.existsSync(targetPath), `${file} 链接到不存在的页面:${href}`);
|
||||
|
||||
if (targetUrl.hash) {
|
||||
const targetSource = fs.readFileSync(targetPath, 'utf8');
|
||||
const anchor = decodeURIComponent(targetUrl.hash.slice(1));
|
||||
assert.ok(
|
||||
targetSource.includes(`id="${anchor}"`) || targetSource.includes(`name="${anchor}"`),
|
||||
`${file} 链接到不存在的锚点:${href}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('个人中心双列表单从顶部对齐,带辅助按钮的字段不会拉高相邻输入框', () => {
|
||||
const source = read('public/css/profile-module.css');
|
||||
|
||||
assert.match(source, /\.editor-two\s*\{[^}]*align-items:\s*start;/);
|
||||
});
|
||||
|
||||
test('安全设置只在页头保留退出登录,注销区只处理注销流程', () => {
|
||||
const source = read('profile-security.html');
|
||||
const logoutControls = source.match(/data-security-logout/g) || [];
|
||||
|
||||
assert.equal(logoutControls.length, 1);
|
||||
assert.match(source, /class="module-panel security-danger"[\s\S]*?class="btn danger[^\"]*"[^>]*data-security-submit/);
|
||||
});
|
||||
|
||||
test('推广收益使用项目统一上传控件样式', () => {
|
||||
const source = read('profile-earnings.html');
|
||||
|
||||
assert.match(source, /id="payoutQrFile"[^>]*class="upload-input"/);
|
||||
});
|
||||
|
||||
test('全量浏览器审计会把缺失 Mock 当成失败,而不是截图后继续通过', () => {
|
||||
const source = read('tests/browser-click-smoke.mjs');
|
||||
|
||||
assert.match(source, /mockTransportErrors:\s*bodyText\.split\(String\.fromCharCode\(10\)\)[\s\S]*?includes\('browser mock missing:'\)/);
|
||||
assert.match(source, /type:\s*'browser-mock-missing'/);
|
||||
});
|
||||
|
||||
test('浏览器测试数据不再把广告图当作家谱内容图', () => {
|
||||
const source = read('tests/browser-click-smoke.mjs');
|
||||
|
||||
assert.doesNotMatch(source, /localImage\('ad-/);
|
||||
});
|
||||
|
||||
test('个人中心列表按钮去除浏览器默认描边,删除操作使用统一危险层级', () => {
|
||||
const moduleCss = read('public/css/profile-module.css');
|
||||
const detailScripts = [
|
||||
'public/js/article-pages.js',
|
||||
'public/js/album-pages.js',
|
||||
'public/js/ceremony-admin-pages.js',
|
||||
'public/js/growth-pages.js',
|
||||
'public/js/merit-pages.js',
|
||||
'public/js/memo-pages.js',
|
||||
'public/js/relative-pages.js',
|
||||
'public/js/video-pages.js'
|
||||
];
|
||||
|
||||
assert.match(moduleCss, /\.row-actions button\.pill\s*\{[^}]*border:\s*0;[^}]*font:\s*inherit;[^}]*cursor:\s*pointer;/);
|
||||
detailScripts.forEach((file) => {
|
||||
assert.doesNotMatch(read(file), /class="btn ghost"[^>]*data-[a-z-]*delete/);
|
||||
});
|
||||
assert.doesNotMatch(read('public/js/feed-pages.js'), /class="pill"[^>]*data-feed-(?:comment-)?delete/);
|
||||
});
|
||||
|
||||
test('手机端列表操作提供可靠触控尺寸', () => {
|
||||
const source = read('public/css/profile-module.css');
|
||||
|
||||
assert.match(source, /@media \(max-width: 720px\)[\s\S]*?\.row-actions \.pill\s*\{[^}]*min-height:\s*44px;/);
|
||||
});
|
||||
|
||||
test('同页记录详情会保留选中编号并把焦点移到详情标题', () => {
|
||||
const flows = [
|
||||
['profile-growth.html', 'public/js/growth-pages.js', 'data-growth-detail-heading', 'buildListUrl'],
|
||||
['profile-memo.html', 'public/js/memo-pages.js', 'data-memo-detail-heading', 'buildListUrl'],
|
||||
['profile-merit.html', 'public/js/merit-pages.js', 'data-merit-detail-heading', 'buildListUrl'],
|
||||
['profile-relative.html', 'public/js/relative-pages.js', 'data-relative-detail-heading', 'buildListUrl']
|
||||
];
|
||||
|
||||
assert.match(read('public/js/profile-common.js'), /function revealDetail\([^)]+\)[\s\S]*?scrollIntoView[\s\S]*?focus/);
|
||||
flows.forEach(([page, script, heading, listUrlBuilder]) => {
|
||||
assert.match(read(page), new RegExp(`tabindex="-1" ${heading}`));
|
||||
assert.match(read(script), new RegExp(`history\\.replaceState\\(null, '', ${listUrlBuilder}`));
|
||||
assert.match(read(script), new RegExp(`ProfileUI\\.revealDetail\\(query\\('\\[${heading}\\]'\\)\\)`));
|
||||
});
|
||||
});
|
||||
|
||||
test('动态编辑页按新建和编辑状态使用一致文案', () => {
|
||||
const page = read('profile-feed-edit.html');
|
||||
const script = read('public/js/feed-pages.js');
|
||||
|
||||
assert.match(page, /data-feed-editor-kicker>发布动态</);
|
||||
assert.match(page, /data-feed-editor-title>发布动态</);
|
||||
assert.match(script, /var editorTitle = isEditing \? '编辑动态' : '发布动态';/);
|
||||
assert.match(script, /button\.textContent = isEditing \? '保存修改' : '发布动态';/);
|
||||
});
|
||||
|
||||
test('贺礼邀请使用明确的关联活动入口', () => {
|
||||
const source = read('profile-gift.html');
|
||||
|
||||
assert.match(source, /<h2>关联活动<\/h2>/);
|
||||
assert.match(source, />管理关联活动<\/a>/);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const InvitePages = require('../public/js/family-invite-pages.js');
|
||||
const ReminderPages = require('../public/js/profile-reminder-pages.js');
|
||||
const LifecyclePages = require('../public/js/genealogy-lifecycle-pages.js');
|
||||
const DocumentPages = require('../public/js/person-document-pages.js');
|
||||
const EarningPages = require('../public/js/earning-pages.js');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const genealogyId = '2062179707935264701';
|
||||
|
||||
test('邀请列表保留长 ID、转义服务端文本并只允许撤销待使用邀请', () => {
|
||||
const html = InvitePages.renderInvitations([{
|
||||
inviteId: '2062179707935264702',
|
||||
genealogyId,
|
||||
genealogyName: '<叶氏家谱>',
|
||||
lineagePersonName: '叶一',
|
||||
status: 'ACTIVE',
|
||||
expiresAt: '2026-09-01T00:00:00+08:00'
|
||||
}]);
|
||||
|
||||
assert.match(html, /<叶氏家谱>/);
|
||||
assert.match(html, /data-invite-revoke="2062179707935264702"/);
|
||||
assert.doesNotMatch(InvitePages.renderInvitations([{
|
||||
inviteId: '2062179707935264702', genealogyId, genealogyName: '叶氏家谱', status: 'REDEEMED'
|
||||
}]), /data-invite-revoke/);
|
||||
});
|
||||
|
||||
test('资料提醒只接受具备人物、姓名和缺项的记录', () => {
|
||||
assert.deepEqual(ReminderPages.normalizeReminder({
|
||||
personId: '2062179707935264703', displayName: '叶一', missingField: 'birthDate',
|
||||
missingFieldLabel: '出生日期', handlingStatus: 'PENDING'
|
||||
}), {
|
||||
personId: '2062179707935264703', displayName: '叶一', label: '出生日期',
|
||||
handlingStatus: 'PENDING', updatedAt: ''
|
||||
});
|
||||
assert.equal(ReminderPages.normalizeReminder({ personId: 'x', displayName: '叶一', missingField: 'birthDate' }), null);
|
||||
});
|
||||
|
||||
test('永久删除能力将稳定阻断码转换为用户可读原因', () => {
|
||||
assert.equal(LifecyclePages.capabilityMessage({
|
||||
canDeletePermanently: false, disabledReasons: ['NOT_OWNER']
|
||||
}), '只有谱主可以永久删除家谱');
|
||||
assert.match(LifecyclePages.capabilityMessage({
|
||||
canDeletePermanently: true, verifiedMobileMasked: '138****0000', disabledReasons: []
|
||||
}), /138\*\*\*\*0000/);
|
||||
});
|
||||
|
||||
test('重要证件请求严格校验人物、类型、标题和脱敏字段', () => {
|
||||
const body = DocumentPages.buildDocumentBody({
|
||||
lineagePersonId: '2062179707935264703', documentType: 'id_card',
|
||||
documentTitle: '身份证', maskedIdentifier: '310***********1234', description: '仅供家族档案使用'
|
||||
});
|
||||
|
||||
assert.equal(DocumentPages.validateDocumentBody(body), '');
|
||||
assert.match(DocumentPages.validateDocumentBody({ ...body, documentTitle: '' }), /标题/);
|
||||
assert.match(DocumentPages.renderDocuments([{
|
||||
documentId: '2062179707935264704', lineagePersonId: '2062179707935264703',
|
||||
lineagePersonName: '<叶一>', documentType: 'id_card', documentTypeLabel: '身份证',
|
||||
documentTitle: '身份证', resources: [{ resourceId: '2062179707935264705', usageType: 'FRONT' }],
|
||||
contentProtected: true, contentUnlocked: false, canEdit: true, canDelete: true
|
||||
}]), /<叶一>|data-document-resource/);
|
||||
});
|
||||
|
||||
test('提现请求使用后端幂等合同并校验余额和收款码', () => {
|
||||
const body = EarningPages.buildWithdrawalBody({
|
||||
amount: '88.50', payoutQrOssId: '2062179707935264706', payoutAccountName: '叶一'
|
||||
}, 'pc-request-1');
|
||||
|
||||
assert.deepEqual(body, {
|
||||
requestId: 'pc-request-1', amount: '88.50', payoutQrOssId: '2062179707935264706', payoutAccountName: '叶一'
|
||||
});
|
||||
assert.equal(EarningPages.validateWithdrawalBody(body, '100.00'), '');
|
||||
assert.match(EarningPages.validateWithdrawalBody({ ...body, amount: '100.01' }, '100.00'), /可用余额/);
|
||||
assert.match(EarningPages.validateWithdrawalBody({ ...body, amount: '9999999999999999.99' }, '9999999999999999.98'), /可用余额/);
|
||||
});
|
||||
|
||||
test('新增 PC 闭环页面加载统一请求层和对应运行时', () => {
|
||||
[
|
||||
['profile-invite.html', 'family-invite-pages.js'],
|
||||
['profile-data-reminders.html', 'profile-reminder-pages.js'],
|
||||
['profile-family-settings.html', 'genealogy-lifecycle-pages.js'],
|
||||
['profile-documents.html', 'person-document-pages.js'],
|
||||
['profile-earnings.html', 'earning-pages.js']
|
||||
].forEach(([file, runtime]) => {
|
||||
const source = fs.readFileSync(path.join(root, file), 'utf8');
|
||||
assert.match(source, /utils\/ApiClient\.js/, `${file} 未加载统一请求层`);
|
||||
assert.match(source, new RegExp(runtime.replace('.', '\\.')), `${file} 未加载 ${runtime}`);
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"/);
|
||||
});
|
||||
});
|
||||
+11
-10
@@ -108,22 +108,23 @@ test('个人中心将参考账户与家谱能力映射到 PC 工作台,未声
|
||||
assert.match(profile, /data-logout-open/);
|
||||
assert.match(services, /href="help\.html"/);
|
||||
assert.match(services, /href="about\.html"/);
|
||||
assert.match(services, /应用分享与收益[\s\S]+?当前不提供不可用的跳转入口/);
|
||||
assert.match(services, /个性化推荐[\s\S]+?暂不能设置推荐开关/);
|
||||
assert.match(services, /href="profile-earnings\.html"[\s\S]+?查看推广收益、可用余额、流水和提现记录/);
|
||||
assert.match(services, /个性化推荐[\s\S]+?推荐设置正在完善,目前不会保存开关状态/);
|
||||
assert.doesNotMatch(services, /data-(?:share|withdraw|recommendation)-(?:form|toggle)/);
|
||||
assert.doesNotMatch(share, /data-feature-status="pending"|pending-pages\.js|data-(?:share|withdraw)-(?:form|toggle)/);
|
||||
assert.match(share, /当前 PC 未声明应用分享、奖励、收益或提现的正式接口/);
|
||||
assert.match(familyHome, /class="module-card is-unavailable" aria-disabled="true">\s*<span class="icon">邀<\/span>\s*<h3>邀请家人<\/h3>/);
|
||||
assert.match(familyHome, /重要证件[\s\S]+?暂不能查看或保存/);
|
||||
assert.match(familyHome, /删除家谱[\s\S]+?暂不能查看或执行删除操作/);
|
||||
assert.doesNotMatch(familyHome, /data-genealogy-delete|deleteGenealogy/);
|
||||
assert.match(share, /应用邀请暂以移动端为主;这里可查看推广收益、流水和提现记录/);
|
||||
assert.match(familyHome, /href="profile-invite\.html"[^>]*data-family-home-management/);
|
||||
assert.match(familyHome, /href="profile-documents\.html"/);
|
||||
assert.match(familyHome, /href="profile-family-settings\.html"[^>]*data-family-home-management/);
|
||||
assert.match(content, /亲友往来(贺礼簿)/);
|
||||
assert.match(content, /备忘录(家族恩人)/);
|
||||
assert.doesNotMatch(apiClient, /\/pages\/(?:mine|index)\//);
|
||||
assert.doesNotMatch(apiClient, /deleteGenealogy/);
|
||||
assert.doesNotMatch(apiClient, /(?:withdrawal|shareReward|recommendationPreference|documentRecords)/);
|
||||
assert.match(verification, /资料提醒、家谱邀请、家谱删除、家谱排序、分享奖励、推广收益与提现/);
|
||||
assert.match(mapping, /`Jiapu-App` 只作为产品信息架构参考/);
|
||||
assert.match(apiClient, /genealogyProfileReminders|issueGenealogyInvitation|permanentlyDeleteGenealogy/);
|
||||
assert.match(apiClient, /createEarningWithdrawal|personDocuments|vipPaymentStatus/);
|
||||
assert.doesNotMatch(apiClient, /shareReward|recommendationPreference/);
|
||||
assert.match(verification, /资料提醒、家谱邀请、重要证件、家谱生命周期、推广收益与提现、VIP 微信扫码支付/);
|
||||
assert.match(mapping, /参考项目固定为 `C:\\Users\\Rain\\Desktop\\job\\Jiapu-App`,只用于核对产品信息架构/);
|
||||
['profile-data.html', 'profile-security.html', 'profile-family-home.html', 'profile-tree.html', 'profile-feed.html', 'profile-services.html'].forEach((page) => {
|
||||
assert.match(mapping, new RegExp('`' + page.replace('.', '\\.') + '`'));
|
||||
assert.equal(fs.existsSync(path.join(root, page)), true, `映射页面不存在:${page}`);
|
||||
|
||||
+12
-16
@@ -6,13 +6,9 @@ const test = require('node:test');
|
||||
const PageAvailability = require('../public/js/pending-pages.js');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const familyPendingPages = [
|
||||
'profile-invite.html',
|
||||
];
|
||||
const familyPendingPages = [];
|
||||
|
||||
const contentPendingPages = [
|
||||
'profile-data-reminders.html'
|
||||
];
|
||||
const contentPendingPages = [];
|
||||
|
||||
const pendingPages = familyPendingPages.concat(contentPendingPages);
|
||||
|
||||
@@ -42,20 +38,20 @@ test('待开发页面显式加载状态脚本', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('缺少 PC 接口契约的页面不展示静态成员、邀请或奖励数据', () => {
|
||||
test('邀请和资料提醒页面使用 PC 接口,不展示静态伪数据', () => {
|
||||
const reminders = read('profile-data-reminders.html');
|
||||
const invite = read('profile-invite.html');
|
||||
const share = read('profile-share.html');
|
||||
|
||||
assert.match(reminders, /当前 PC 接口未提供资料缺项提醒列表/);
|
||||
assert.match(reminders, /public\/js\/profile-reminder-pages\.js/);
|
||||
assert.doesNotMatch(reminders, /鹿野 Loyel|张三|缺少出生日期/);
|
||||
assert.match(invite, /当前 PC 接口未提供邀请码/);
|
||||
assert.match(invite, /当前 PC 接口未提供邀请链接/);
|
||||
assert.doesNotMatch(invite, /data-invite-code|data-invite-link|加载中/);
|
||||
assert.match(share, /当前 PC 接口未提供分享链接或二维码/);
|
||||
assert.match(share, /当前 PC 接口未提供分享记录/);
|
||||
assert.match(share, /当前 PC 接口未提供收益账户或余额/);
|
||||
assert.match(share, /当前 PC 接口未提供提现方式、申请或记录/);
|
||||
assert.match(invite, /public\/js\/family-invite-pages\.js/);
|
||||
assert.match(invite, /data-invite-link/);
|
||||
assert.doesNotMatch(invite, /当前 PC 接口未提供邀请码|当前 PC 接口未提供邀请链接/);
|
||||
assert.match(share, /分享链接和二维码暂未开放,请先使用移动端分享/);
|
||||
assert.match(share, /分享记录暂未开放;推广收益和提现结果可在下方查看/);
|
||||
assert.match(share, /href="profile-earnings\.html"/);
|
||||
assert.doesNotMatch(share, /当前 PC 接口未提供收益账户或余额|当前 PC 接口未提供提现方式、申请或记录/);
|
||||
assert.doesNotMatch(share, /data-member-overview|data-invite-link|data-withdraw(?:-form|-record)|3 人访问|1 人完成注册|加载中/);
|
||||
assert.doesNotMatch(share, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(share, /class="module-card is-unavailable" aria-disabled="true"/);
|
||||
@@ -115,7 +111,7 @@ test('待开发页面仅放行显式标记的可用功能入口', () => {
|
||||
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: '#section' })), false);
|
||||
});
|
||||
|
||||
test('待开发邀请页允许返回已接入的成员管理', () => {
|
||||
test('邀请页允许返回已接入的成员管理', () => {
|
||||
const invite = read('profile-invite.html');
|
||||
|
||||
assert.match(invite, /href="profile-family-admin\.html"[^>]*data-feature-link="available"[^>]*data-genealogy-context-link/);
|
||||
|
||||
@@ -167,7 +167,7 @@ test('地区搜索复用统一状态组件并区分空结果、失败和无权
|
||||
test('个人资料页不伪造亲属数据,并明确指向当前家谱的成员管理', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-data.html'), 'utf8');
|
||||
|
||||
assert.match(source, /当前 PC 资料接口未提供个人亲属关系列表/);
|
||||
assert.match(source, /个人资料页不单独维护亲属关系;请先选择家谱,再到成员管理或世系图中查看和维护/);
|
||||
assert.match(source, /href="profile-family-admin\.html"[^>]*data-genealogy-context-link/);
|
||||
assert.match(source, /api-state--empty/);
|
||||
assert.doesNotMatch(source, /张三|父亲 · 已绑定成员资料|邀请激活/);
|
||||
|
||||
@@ -103,7 +103,6 @@ test('亲友记录响应使用 PC RelativeRecordVo 并保留 BigDecimal 字符
|
||||
eventTime: '2026-07-24 09:30:00',
|
||||
giftAmount: '500.50',
|
||||
recordContent: '出席',
|
||||
mediaOssIds: '2060000000000000004',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: '往来记录'
|
||||
@@ -122,7 +121,6 @@ test('亲友记录响应使用 PC RelativeRecordVo 并保留 BigDecimal 字符
|
||||
eventTime: '2026-07-24 09:30:00',
|
||||
giftAmount: '500.50',
|
||||
recordContent: '出席',
|
||||
mediaOssIds: '2060000000000000004',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: '往来记录'
|
||||
@@ -148,7 +146,6 @@ test('亲友记录渲染业务字段但不暴露手机号、OSS ID 或未转义
|
||||
eventTime: '2026-07-24 09:30:00',
|
||||
giftAmount: '500.50',
|
||||
recordContent: '<img src=x onerror=alert(1)>出席',
|
||||
mediaOssIds: '4,5',
|
||||
sortOrder: 1,
|
||||
status: '0'
|
||||
});
|
||||
@@ -157,7 +154,7 @@ test('亲友记录渲染业务字段但不暴露手机号、OSS ID 或未转义
|
||||
assert.doesNotMatch(html, /<script>|<img/);
|
||||
assert.match(html, /出席/);
|
||||
assert.match(html, /500\.50/);
|
||||
assert.match(html, /2 个附件/);
|
||||
assert.match(html, /未上传/);
|
||||
assert.doesNotMatch(html, /19100000000|>4<|>5</);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,16 @@ const test = require('node:test');
|
||||
|
||||
const VideoPages = require('../public/js/video-pages.js');
|
||||
|
||||
function fileAccess(name, mediaType, url) {
|
||||
return {
|
||||
fileId: '2060000000000000090',
|
||||
ossId: '2060000000000000091',
|
||||
fileName: name,
|
||||
mediaType,
|
||||
accessUrl: url
|
||||
};
|
||||
}
|
||||
|
||||
test('视频页只从 URL 读取真实家谱和视频编号', () => {
|
||||
assert.equal(VideoPages.getCurrentGenealogyId('?genealogyId=2060000000000000001&videoId=2060000000000000002'), '2060000000000000001');
|
||||
assert.equal(VideoPages.getCurrentVideoId('?genealogyId=2060000000000000001&videoId=2060000000000000002'), '2060000000000000002');
|
||||
@@ -54,8 +64,8 @@ test('视频响应使用 PC VideoVo 字段并拒绝不安全长 ID', () => {
|
||||
surname: '叶',
|
||||
videoTitle: '祭祖活动记录',
|
||||
videoDesc: '活动影像',
|
||||
coverOssId: '2060000000000000003',
|
||||
videoOssId: '2060000000000000004',
|
||||
coverFile: fileAccess('cover.jpg', 'image/jpeg', 'https://files.example/video-cover.jpg'),
|
||||
videoFile: fileAccess('video.mp4', 'video/mp4', 'https://files.example/video.mp4'),
|
||||
durationSeconds: 180,
|
||||
publisherUserId: '2060000000000000005',
|
||||
publisherNickName: '叶子',
|
||||
@@ -73,8 +83,18 @@ test('视频响应使用 PC VideoVo 字段并拒绝不安全长 ID', () => {
|
||||
surname: '叶',
|
||||
videoTitle: '祭祖活动记录',
|
||||
videoDesc: '活动影像',
|
||||
coverOssId: '2060000000000000003',
|
||||
videoOssId: '2060000000000000004',
|
||||
coverFile: {
|
||||
fileId: '2060000000000000090',
|
||||
fileName: 'cover.jpg',
|
||||
mediaType: 'image/jpeg',
|
||||
accessUrl: 'https://files.example/video-cover.jpg'
|
||||
},
|
||||
videoFile: {
|
||||
fileId: '2060000000000000090',
|
||||
fileName: 'video.mp4',
|
||||
mediaType: 'video/mp4',
|
||||
accessUrl: 'https://files.example/video.mp4'
|
||||
},
|
||||
durationSeconds: 180,
|
||||
publisherUserId: '2060000000000000005',
|
||||
publisherNickName: '叶子',
|
||||
@@ -89,7 +109,7 @@ test('视频响应使用 PC VideoVo 字段并拒绝不安全长 ID', () => {
|
||||
videoId: Number('2060000000000000001'),
|
||||
genealogyId: '2',
|
||||
videoTitle: '标题',
|
||||
videoOssId: '3',
|
||||
videoFile: fileAccess('video.mp4', 'video/mp4', 'https://files.example/video.mp4'),
|
||||
status: '0'
|
||||
}), null);
|
||||
});
|
||||
@@ -100,8 +120,8 @@ test('视频渲染展示业务信息但不直接暴露 OSS ID 和发布人手机
|
||||
genealogyId: '2',
|
||||
videoTitle: '<script>alert(1)</script>',
|
||||
videoDesc: '活动影像',
|
||||
coverOssId: '3',
|
||||
videoOssId: '4',
|
||||
coverFile: fileAccess('cover.jpg', 'image/jpeg', 'https://files.example/video-cover.jpg'),
|
||||
videoFile: fileAccess('video.mp4', 'video/mp4', 'https://files.example/video.mp4'),
|
||||
durationSeconds: 180,
|
||||
publisherUserId: '5',
|
||||
publisherNickName: '叶子',
|
||||
|
||||
+25
-17
@@ -9,6 +9,7 @@ const root = path.resolve(__dirname, '..');
|
||||
const packageId = '2062179707935264769';
|
||||
const genealogyId = '2062179707935264770';
|
||||
const orderId = '2062179707935264771';
|
||||
const transactionId = '2062179707935264772';
|
||||
|
||||
function packageFixture(overrides) {
|
||||
return Object.assign({
|
||||
@@ -49,7 +50,10 @@ function orderFixture(overrides) {
|
||||
payTime: null,
|
||||
expireTime: null,
|
||||
status: '0',
|
||||
remark: ''
|
||||
remark: '',
|
||||
transactionId,
|
||||
canRefreshPayment: true,
|
||||
canClose: true
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
@@ -124,7 +128,7 @@ test('家谱选择项只接受响应中的稳定 ID 和名称', () => {
|
||||
], 'unsafe'), '');
|
||||
});
|
||||
|
||||
test('订单请求只构造 AppVipOrderBody 且页面默认省略支付方式', () => {
|
||||
test('订单请求只构造 PcVipOrderBody,支付方式由后端确定', () => {
|
||||
assert.deepEqual(VipPages.buildVipOrderBody({
|
||||
packageId: ' ' + packageId + ' ',
|
||||
genealogyId: ' ' + genealogyId + ' ',
|
||||
@@ -140,8 +144,7 @@ test('订单请求只构造 AppVipOrderBody 且页面默认省略支付方式',
|
||||
packageId,
|
||||
payType: ' wechat '
|
||||
}), {
|
||||
packageId,
|
||||
payType: 'wechat'
|
||||
packageId
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,10 +156,7 @@ test('订单请求要求安全套餐 ID,并只接受已确认的可选值', ()
|
||||
packageId,
|
||||
genealogyId: 'unsafe'
|
||||
}), /家谱/);
|
||||
assert.match(VipPages.validateVipOrderBody({
|
||||
packageId,
|
||||
payType: 'alipay'
|
||||
}), /支付方式/);
|
||||
assert.equal(VipPages.validateVipOrderBody({ packageId, payType: 'alipay' }), '');
|
||||
});
|
||||
|
||||
test('VIP 订单隐藏账号字段并校验金额和状态', () => {
|
||||
@@ -175,7 +175,10 @@ test('VIP 订单隐藏账号字段并校验金额和状态', () => {
|
||||
payTime: '',
|
||||
expireTime: '',
|
||||
status: '0',
|
||||
remark: ''
|
||||
remark: '',
|
||||
transactionId,
|
||||
canRefreshPayment: true,
|
||||
canClose: true
|
||||
});
|
||||
assert.equal(VipPages.normalizeVipOrder(orderFixture({
|
||||
orderId: Number.MAX_SAFE_INTEGER + 1
|
||||
@@ -191,7 +194,7 @@ test('VIP 订单隐藏账号字段并校验金额和状态', () => {
|
||||
})), null);
|
||||
});
|
||||
|
||||
test('套餐和订单渲染不出现内部账号信息或伪支付操作', () => {
|
||||
test('套餐和订单渲染不出现内部账号信息,并按服务端能力提供支付操作', () => {
|
||||
const packagesHtml = VipPages.renderVipPackages([packageFixture({
|
||||
packageName: '<年度会员>'
|
||||
})], packageId);
|
||||
@@ -204,7 +207,9 @@ test('套餐和订单渲染不出现内部账号信息或伪支付操作', () =>
|
||||
assert.match(packagesHtml, /<年度会员>/);
|
||||
assert.match(packagesHtml, new RegExp('data-vip-package-id="' + packageId + '"'));
|
||||
assert.match(ordersHtml, /待支付/);
|
||||
assert.doesNotMatch(combined, /secret-user|secret-phone|appUserId|appUserPhone|立即支付|模拟|取消订单|退款/);
|
||||
assert.match(ordersHtml, /data-vip-payment-refresh/);
|
||||
assert.match(ordersHtml, /data-vip-payment-close/);
|
||||
assert.doesNotMatch(combined, /secret-user|secret-phone|appUserId|appUserPhone|模拟支付|取消订单|退款/);
|
||||
});
|
||||
|
||||
test('套餐渲染接受初始化流程已经规范化的数据', () => {
|
||||
@@ -216,7 +221,7 @@ test('套餐渲染接受初始化流程已经规范化的数据', () => {
|
||||
assert.doesNotMatch(html, /当前没有可用会员套餐/);
|
||||
});
|
||||
|
||||
test('会员服务页只从接口结果选择套餐和家谱,不暴露业务 ID 或未开放支付操作', () => {
|
||||
test('会员服务页只从接口结果选择套餐和家谱,并使用微信扫码支付', () => {
|
||||
const source = fs.readFileSync(path.join(root, 'profile-services.html'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"/);
|
||||
@@ -231,7 +236,9 @@ test('会员服务页只从接口结果选择套餐和家谱,不暴露业务 I
|
||||
assert.match(source, /data-api-state="loading"/);
|
||||
assert.doesNotMatch(source, /<input[^>]+name="(?:packageId|genealogyId|orderId)"/);
|
||||
assert.doesNotMatch(source, /name="payType"/);
|
||||
assert.doesNotMatch(source, /立即支付|模拟支付|取消订单|退款/);
|
||||
assert.match(source, /data-vip-payment-panel/);
|
||||
assert.match(source, /public\/js\/qrcode\.min\.js/);
|
||||
assert.doesNotMatch(source, /模拟支付|取消订单|退款/);
|
||||
assert.ok(source.indexOf('src="public/js/profile-common.js"') < source.indexOf('src="public/js/vip-pages.js"'));
|
||||
});
|
||||
|
||||
@@ -244,16 +251,17 @@ test('会员服务页补齐桌面端设置与协议入口,不伪造账户接
|
||||
assert.match(source, /href="children-privacy\.html"/);
|
||||
assert.match(source, /href="terms\.html"/);
|
||||
assert.match(source, /个性化推荐/);
|
||||
assert.match(source, /当前 PC 接口未提供推荐偏好的读取或保存/);
|
||||
assert.match(source, /推荐设置正在完善,目前不会保存开关状态/);
|
||||
assert.doesNotMatch(source, /data-setting-form|data-withdraw-form|data-share-reward/);
|
||||
assert.doesNotMatch(source, /data-recommendation-toggle|name="recommendation"/);
|
||||
});
|
||||
|
||||
test('会员服务运行时并行读取套餐、我的家谱和订单,并在创建后重新读取核验', () => {
|
||||
test('会员服务运行时读取支付能力并展示后端返回的 NATIVE 支付单', () => {
|
||||
const source = fs.readFileSync(path.join(root, 'public/js/vip-pages.js'), 'utf8');
|
||||
|
||||
assert.match(source, /Promise\.all\(\[api\.vipPackages\(\), api\.genealogiesMine\(\), api\.vipOrders\(\)\]\)/);
|
||||
assert.match(source, /created = normalizeVipOrder\(await api\.createVipOrder\(body\)\)/);
|
||||
assert.match(source, /api\.vipCapability\(\), api\.vipPackages\(\), api\.genealogiesMine\(\), api\.vipOrders\(\)/);
|
||||
assert.match(source, /payment = normalizePaymentOrder\(await api\.createVipOrder\(body\)\)/);
|
||||
assert.match(source, /showPayment\(payment\)/);
|
||||
assert.match(source, /refreshed = normalizeVipOrders\(await api\.vipOrders\(\)\)/);
|
||||
assert.match(source, /item\.orderId === created\.orderId/);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,9 @@ test('公共富文本适配层只使用 wangEditor 并在提交前同步 textare
|
||||
assert.match(source, /root\.wangEditor/);
|
||||
assert.match(source, /wangEditor\.createEditor/);
|
||||
assert.match(source, /wangEditor\.createToolbar/);
|
||||
assert.match(source, /button\[data-tooltip\]/);
|
||||
assert.match(source, /setAttribute\('aria-label'/);
|
||||
assert.match(source, /MutationObserver/);
|
||||
assert.match(source, /documentRef\.addEventListener\('submit'/);
|
||||
assert.doesNotMatch(source, /KindEditor/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user