feat(api): 添加帮助中心反馈系统和VIP服务功能
- 在ApiClient中新增submitFeedback、myFeedback、helpArticles、helpArticleDetail、 siteArticles、promotions、vipPackages、createVipOrder、vipOrders等方法 - 添加帮助文章和站点资讯的参数验证逻辑 - 更新测试文件添加新的API方法测试用例 - 在HTML页面中添加反馈、帮助和VIP服务相关页面的脚本引用 - 更新加入家谱页面为完整的申请流程界面 - 修改资讯详情页面为站点资讯展示页面 - 更新AxiosRequestUtil中认证处理逻辑 - 添加世系树渲染的HTML生成函数用于页面复用 - 更新文档中的API契约说明和页面规划
This commit is contained in:
@@ -32,6 +32,10 @@ test('API client exposes latest document-defined PC operations', () => {
|
||||
'genealogiesMine', 'genealogyOptions', 'genealogyDetail', 'genealogyOverview', 'genealogyQuota',
|
||||
'createGenealogy', 'applyToGenealogy', 'myGenealogyJoinApplies',
|
||||
'pendingGenealogyJoinApplies', 'auditGenealogyJoinApply', 'cancelGenealogyJoinApply',
|
||||
'submitFeedback', 'myFeedback',
|
||||
'helpArticles', 'helpArticleDetail', 'siteArticles',
|
||||
'promotions',
|
||||
'vipPackages', 'createVipOrder', 'vipOrders',
|
||||
'notifications', 'notificationDetail', 'unreadNotificationCount', 'markNotificationRead', 'markAllNotificationsRead',
|
||||
'feeds', 'feedsPage', 'feedDetail', 'createFeed', 'updateFeed', 'deleteFeed',
|
||||
'likeFeed', 'unlikeFeed', 'feedComments', 'feedCommentsPage', 'createFeedComment',
|
||||
@@ -506,6 +510,110 @@ test('genealogy quota uses the current PC path without manufacturing a genealogy
|
||||
await client.genealogyQuota();
|
||||
});
|
||||
|
||||
test('help article methods use authenticated PC paths and whitelist the optional category', 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.helpArticles({ helpCategory: ' account ', keyword: 'must-drop' });
|
||||
await client.helpArticleDetail('2062179707935264769');
|
||||
|
||||
assert.deepEqual(calls.map((config) => [
|
||||
config.method,
|
||||
config.url,
|
||||
config.params,
|
||||
config.headers.Authorization
|
||||
]), [
|
||||
['get', '/genealogy/pc/help-articles', { helpCategory: ' account ' }, 'Bearer access-token'],
|
||||
['get', '/genealogy/pc/help-articles/2062179707935264769', undefined, 'Bearer access-token']
|
||||
]);
|
||||
});
|
||||
|
||||
test('promotion method uses the authenticated PC path and whitelists platform query', 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.promotions({
|
||||
platform: 'pc',
|
||||
keyword: 'must-drop'
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.map((config) => [
|
||||
config.method,
|
||||
config.url,
|
||||
config.params,
|
||||
config.headers.Authorization
|
||||
]), [
|
||||
['get', '/genealogy/pc/promotions', { platform: 'pc' }, 'Bearer access-token']
|
||||
]);
|
||||
assert.throws(
|
||||
() => client.promotions({ platform: 'desktop' }),
|
||||
/不支持的推广平台/
|
||||
);
|
||||
});
|
||||
|
||||
test('site article method uses the public PC path and validates SQL query values', 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.siteArticles({
|
||||
articleType: 'notice',
|
||||
limit: 100,
|
||||
keyword: 'must-drop'
|
||||
});
|
||||
await client.siteArticles({ limit: 100 });
|
||||
|
||||
assert.deepEqual(calls.map((config) => [
|
||||
config.method,
|
||||
config.url,
|
||||
config.params,
|
||||
config.headers.Authorization
|
||||
]), [
|
||||
['get', '/genealogy/pc/site/articles', { articleType: 'notice', limit: 100 }, undefined],
|
||||
['get', '/genealogy/pc/site/articles', { limit: 100 }, undefined]
|
||||
]);
|
||||
assert.throws(
|
||||
() => client.siteArticles({ articleType: 'culture', limit: 100 }),
|
||||
/不支持的资讯类型/
|
||||
);
|
||||
[0, -1, 1.5, 101, '100'].forEach((limit) => {
|
||||
assert.throws(
|
||||
() => client.siteArticles({ limit }),
|
||||
/资讯数量限制/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('genealogy context methods obtain IDs from the real PC list and detail paths', async () => {
|
||||
const calls = [];
|
||||
const client = GenealogyApi.createClient({
|
||||
@@ -613,6 +721,72 @@ test('genealogy lifecycle methods use PC paths and strict request bodies', async
|
||||
);
|
||||
});
|
||||
|
||||
test('feedback methods use the PC collection path and AppFeedbackBody 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: [] } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await client.submitFeedback({
|
||||
feedbackType: 'bug',
|
||||
feedbackContent: '页面按钮无响应',
|
||||
contactInfo: '19181970173',
|
||||
feedbackTitle: 'must-drop',
|
||||
appUserId: 'must-drop'
|
||||
});
|
||||
await client.myFeedback();
|
||||
|
||||
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||
['post', '/genealogy/pc/feedback', {
|
||||
feedbackType: 'bug',
|
||||
feedbackContent: '页面按钮无响应',
|
||||
contactInfo: '19181970173'
|
||||
}],
|
||||
['get', '/genealogy/pc/feedback', undefined]
|
||||
]);
|
||||
});
|
||||
|
||||
test('VIP methods use PC package and order paths with AppVipOrderBody 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: [] } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await client.vipPackages();
|
||||
await client.createVipOrder({
|
||||
packageId: '2062179707935264769',
|
||||
genealogyId: '2062179707935264770',
|
||||
payType: 'wechat',
|
||||
appUserId: 'must-drop',
|
||||
payStatus: 'must-drop'
|
||||
});
|
||||
await client.vipOrders();
|
||||
|
||||
assert.deepEqual(calls.map((config) => [config.method, config.url, config.data]), [
|
||||
['get', '/genealogy/pc/vip/packages', undefined],
|
||||
['post', '/genealogy/pc/vip/orders', {
|
||||
packageId: '2062179707935264769',
|
||||
genealogyId: '2062179707935264770',
|
||||
payType: 'wechat'
|
||||
}],
|
||||
['get', '/genealogy/pc/vip/orders', undefined]
|
||||
]);
|
||||
});
|
||||
|
||||
test('notification methods use the current PC list, unread and read paths', async () => {
|
||||
const calls = [];
|
||||
const client = GenealogyApi.createClient({
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const AppPromotionPages = require('../public/js/app-promotion-pages.js');
|
||||
const appPage = fs.readFileSync(path.resolve(__dirname, '../app.html'), 'utf8');
|
||||
|
||||
const promotionId = '2062179707935264769';
|
||||
|
||||
function promotionFixture(overrides) {
|
||||
return Object.assign({
|
||||
promotionId,
|
||||
promotionKey: 'app-download',
|
||||
promotionTitle: '下载移动端',
|
||||
promotionDesc: '随时查看家谱内容',
|
||||
coverOssId: '2062179707935264701',
|
||||
targetUrl: 'https://example.com/download',
|
||||
platform: 'all',
|
||||
sortOrder: 10,
|
||||
status: '0',
|
||||
remark: 'internal-only'
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('推广响应只保留 AppPromotionVo 的安全展示字段并保持长 ID 字符串', () => {
|
||||
assert.deepEqual(AppPromotionPages.normalizePromotion(promotionFixture()), {
|
||||
promotionId,
|
||||
promotionTitle: '下载移动端',
|
||||
promotionDesc: '随时查看家谱内容',
|
||||
targetUrl: 'https://example.com/download'
|
||||
});
|
||||
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({
|
||||
promotionId: Number.MAX_SAFE_INTEGER + 1
|
||||
})), null);
|
||||
});
|
||||
|
||||
test('推广响应拒绝停用记录和空标题', () => {
|
||||
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({ status: '1' })), null);
|
||||
assert.equal(AppPromotionPages.normalizePromotion(promotionFixture({ promotionTitle: ' ' })), null);
|
||||
});
|
||||
|
||||
test('推广列表只接受直接数组且任一非法元素使整批失败', () => {
|
||||
assert.equal(AppPromotionPages.normalizePromotionList([promotionFixture()]).length, 1);
|
||||
assert.deepEqual(AppPromotionPages.normalizePromotionList([]), []);
|
||||
assert.deepEqual(AppPromotionPages.normalizePromotionList({
|
||||
rows: [promotionFixture()]
|
||||
}), []);
|
||||
assert.deepEqual(AppPromotionPages.normalizePromotionList([
|
||||
promotionFixture(),
|
||||
promotionFixture({ promotionId: '' })
|
||||
]), []);
|
||||
});
|
||||
|
||||
test('推广链接只允许绝对 HTTP 或 HTTPS 地址', () => {
|
||||
assert.equal(
|
||||
AppPromotionPages.normalizeTargetUrl(' https://example.com/download '),
|
||||
'https://example.com/download'
|
||||
);
|
||||
assert.equal(AppPromotionPages.normalizeTargetUrl('http://example.com'), 'http://example.com/');
|
||||
['javascript:alert(1)', 'data:text/html,test', '/download', 'download.html', ''].forEach((value) => {
|
||||
assert.equal(AppPromotionPages.normalizeTargetUrl(value), '');
|
||||
});
|
||||
});
|
||||
|
||||
test('推广卡片转义响应并隐藏 OSS ID、后台键、排序、状态和备注', () => {
|
||||
const html = AppPromotionPages.renderPromotionList([promotionFixture({
|
||||
promotionTitle: '<script>alert(1)</script>',
|
||||
promotionDesc: '<img src=x>',
|
||||
coverOssId: 'secret-oss',
|
||||
promotionKey: 'secret-key',
|
||||
remark: 'secret-remark'
|
||||
})]);
|
||||
|
||||
assert.match(html, /<script>/);
|
||||
assert.match(html, /<img src=x>/);
|
||||
assert.match(html, /target="_blank"/);
|
||||
assert.match(html, /rel="noopener noreferrer"/);
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/<script|<img|secret-oss|secret-key|secret-remark|coverOssId|promotionKey|sortOrder|status|remark/
|
||||
);
|
||||
});
|
||||
|
||||
test('危险或缺失的推广链接渲染为不可点击卡片', () => {
|
||||
const html = AppPromotionPages.renderPromotionList([
|
||||
promotionFixture({ targetUrl: 'javascript:alert(1)' })
|
||||
]);
|
||||
|
||||
assert.match(html, /^<article class="promotion-card"/);
|
||||
assert.doesNotMatch(html, /<a(?:\s|>)|href=|target=|rel=/);
|
||||
});
|
||||
|
||||
test('PC 推广列表只请求 SQL 字典确认的 pc 平台', async () => {
|
||||
const calls = [];
|
||||
const api = {
|
||||
async promotions() {
|
||||
calls.push(Array.from(arguments));
|
||||
return [promotionFixture()];
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal((await AppPromotionPages.loadPromotions(api)).length, 1);
|
||||
assert.deepEqual(calls, [[{ platform: 'pc' }]]);
|
||||
await assert.rejects(
|
||||
AppPromotionPages.loadPromotions({
|
||||
async promotions() {
|
||||
return { rows: [promotionFixture()] };
|
||||
}
|
||||
}),
|
||||
/推广列表响应无效/
|
||||
);
|
||||
});
|
||||
|
||||
test('推广区域只把 401 视为登录失效', () => {
|
||||
assert.equal(AppPromotionPages.isUnauthorized({ status: 401 }), true);
|
||||
assert.equal(AppPromotionPages.isUnauthorized({ code: '401' }), true);
|
||||
assert.equal(AppPromotionPages.isUnauthorized({ status: 403 }), false);
|
||||
assert.equal(AppPromotionPages.isUnauthorized(new Error('网络失败')), false);
|
||||
});
|
||||
|
||||
test('推广区域提供明确的未登录和空列表状态', () => {
|
||||
assert.match(
|
||||
AppPromotionPages.renderPromotionLoginRequired(),
|
||||
/<a href="login\.html">立即登录<\/a>/
|
||||
);
|
||||
assert.match(AppPromotionPages.renderPromotionList([]), /当前暂无应用推广/);
|
||||
});
|
||||
|
||||
test('应用下载页只通过页面标记和新推广模块接入,不暴露业务 ID 输入', () => {
|
||||
assert.match(appPage, /<body[^>]*data-promotion-page/);
|
||||
assert.match(appPage, /data-promotion-list/);
|
||||
assert.match(appPage, /src="public\/js\/app-promotion-pages\.js"/);
|
||||
assert.doesNotMatch(appPage, /src="public\/js\/promotion-pages\.js"/);
|
||||
assert.doesNotMatch(appPage, /name="(?:promotionId|coverOssId)"/);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const FamilyHomePages = require('../public/js/family-home-pages.js');
|
||||
|
||||
const genealogyId = '2062179707935264769';
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
function overviewFixture(overrides) {
|
||||
return Object.assign({
|
||||
genealogyId,
|
||||
genealogyNo: 'G20260729001',
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
ancestralHall: '南阳堂',
|
||||
originPlace: '四川成都',
|
||||
regionCode: '510100',
|
||||
regionName: '成都市',
|
||||
regionFullName: '四川省 成都市',
|
||||
addressDetail: '高新区',
|
||||
coverOssId: '2062179707935264700',
|
||||
intro: '叶氏源流',
|
||||
ownerUserId: '2062179707935264701',
|
||||
visibility: '1',
|
||||
joinMode: '1',
|
||||
memberCount: 12,
|
||||
personCount: 36,
|
||||
status: '0',
|
||||
roleType: 'owner',
|
||||
canManage: true,
|
||||
canEditContent: true,
|
||||
memberStatus: '0',
|
||||
joinTime: '2026-07-29 12:00:00'
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('家谱主页只保留 AppGenealogyVo 的安全展示字段并匹配当前上下文', () => {
|
||||
assert.deepEqual(FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture(),
|
||||
genealogyId
|
||||
), {
|
||||
genealogyId,
|
||||
genealogyNo: 'G20260729001',
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
ancestralHall: '南阳堂',
|
||||
originPlace: '四川成都',
|
||||
regionFullName: '四川省 成都市',
|
||||
memberCount: 12,
|
||||
personCount: 36,
|
||||
canManage: true,
|
||||
canEditContent: true
|
||||
});
|
||||
|
||||
assert.equal(FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture({ genealogyId: '2062179707935264770' }),
|
||||
genealogyId
|
||||
), null);
|
||||
assert.equal(FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture({ genealogyId: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
genealogyId
|
||||
), null);
|
||||
});
|
||||
|
||||
test('家谱主页拒绝停用、空名称和非法计数响应', () => {
|
||||
assert.equal(FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture({ status: '1' }),
|
||||
genealogyId
|
||||
), null);
|
||||
assert.equal(FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture({ genealogyName: '' }),
|
||||
genealogyId
|
||||
), null);
|
||||
assert.equal(FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture({ memberCount: -1 }),
|
||||
genealogyId
|
||||
), null);
|
||||
assert.equal(FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture({ personCount: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
genealogyId
|
||||
), null);
|
||||
});
|
||||
|
||||
test('家谱概览渲染转义文本并隐藏内部用户与文件编号', () => {
|
||||
const overview = FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture({
|
||||
genealogyName: '<叶氏家谱>',
|
||||
ancestralHall: '<南阳堂>'
|
||||
}),
|
||||
genealogyId
|
||||
);
|
||||
const html = FamilyHomePages.renderFamilyOverview(overview);
|
||||
|
||||
assert.match(html, /<叶氏家谱>/);
|
||||
assert.match(html, /<南阳堂>/);
|
||||
assert.match(html, /成员 12 人/);
|
||||
assert.match(html, /世系人物 36 人/);
|
||||
assert.doesNotMatch(html, /ownerUserId|coverOssId|206217970793526470[01]/);
|
||||
});
|
||||
|
||||
test('家谱管理入口只接受后端布尔能力字段', () => {
|
||||
assert.equal(FamilyHomePages.shouldShowFamilyManagement(
|
||||
FamilyHomePages.normalizeFamilyOverview(overviewFixture({ canManage: true }), genealogyId)
|
||||
), true);
|
||||
assert.equal(FamilyHomePages.shouldShowFamilyManagement(
|
||||
FamilyHomePages.normalizeFamilyOverview(overviewFixture({ canManage: 'true' }), genealogyId)
|
||||
), false);
|
||||
});
|
||||
|
||||
test('家谱主页只把缺失登录态和 401 视为登录失效', () => {
|
||||
assert.equal(FamilyHomePages.shouldRedirectToLogin({
|
||||
getToken() { return 'token'; }
|
||||
}, { status: 403 }), false);
|
||||
assert.equal(FamilyHomePages.shouldRedirectToLogin({
|
||||
getToken() { return 'token'; }
|
||||
}, { status: 401 }), true);
|
||||
assert.equal(FamilyHomePages.shouldRedirectToLogin({
|
||||
getToken() { return ''; }
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('家谱主页只读取当前家谱概览和世系树', async () => {
|
||||
const calls = [];
|
||||
const tree = [{
|
||||
personId: '2062179707935264770',
|
||||
genealogyId,
|
||||
name: '始祖',
|
||||
spouses: [],
|
||||
children: []
|
||||
}];
|
||||
const result = await FamilyHomePages.loadFamilyHome({
|
||||
genealogyOverview(receivedId) {
|
||||
calls.push(['overview', receivedId]);
|
||||
return Promise.resolve(overviewFixture());
|
||||
},
|
||||
lineageTree(receivedId) {
|
||||
calls.push(['tree', receivedId]);
|
||||
return Promise.resolve(tree);
|
||||
}
|
||||
}, genealogyId);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['overview', genealogyId],
|
||||
['tree', genealogyId]
|
||||
]);
|
||||
assert.deepEqual(result.overview, FamilyHomePages.normalizeFamilyOverview(
|
||||
overviewFixture(),
|
||||
genealogyId
|
||||
));
|
||||
assert.deepEqual(result.tree, tree);
|
||||
});
|
||||
|
||||
test('家谱主页开放真实只读概览且不制造邀请或聚合统计入口', () => {
|
||||
const source = fs.readFileSync(path.join(root, 'profile-family-home.html'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.doesNotMatch(source, /四川武胜汤氏族/);
|
||||
assert.doesNotMatch(source, /href="profile-invite\.html"/);
|
||||
assert.match(source, /PC 暂未开放邀请/);
|
||||
assert.match(source, /data-family-home-title/);
|
||||
assert.match(source, /data-family-home-overview/);
|
||||
assert.match(source, /data-family-home-member-count/);
|
||||
assert.match(source, /data-family-home-person-count/);
|
||||
assert.match(source, /data-family-home-management/);
|
||||
assert.match(source, /data-lineage-home-tree/);
|
||||
assert.match(source, /src="public\/js\/lineage-pages\.js"/);
|
||||
assert.match(source, /src="public\/js\/family-home-pages\.js"/);
|
||||
|
||||
[
|
||||
'profile-feed.html',
|
||||
'profile-tree.html',
|
||||
'profile-article.html',
|
||||
'profile-album.html',
|
||||
'profile-video.html',
|
||||
'profile-merit.html',
|
||||
'profile-ceremony.html'
|
||||
].forEach((href) => {
|
||||
assert.match(
|
||||
source,
|
||||
new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'),
|
||||
`${href} 缺少家谱上下文`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const FeedbackPages = require('../public/js/feedback-pages.js');
|
||||
|
||||
const feedbackId = '2062179707935264769';
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function feedbackFixture(overrides) {
|
||||
return Object.assign({
|
||||
feedbackId,
|
||||
appUserId: '2062179707935264701',
|
||||
appUserNickName: '叶子',
|
||||
appUserPhone: '19100000000',
|
||||
feedbackType: 'bug',
|
||||
feedbackContent: '页面按钮无响应',
|
||||
contactInfo: '19181970173',
|
||||
handleStatus: '2',
|
||||
handleResult: '已经修复',
|
||||
handlerId: '2062179707935264702',
|
||||
handleTime: '2026-07-29 13:00:00',
|
||||
status: '0',
|
||||
remark: '感谢反馈'
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('反馈请求只构造 AppFeedbackBody 并裁剪文本', () => {
|
||||
assert.deepEqual(FeedbackPages.buildFeedbackBody({
|
||||
feedbackType: ' bug ',
|
||||
feedbackContent: ' 页面按钮无响应 ',
|
||||
contactInfo: ' 19181970173 ',
|
||||
feedbackTitle: 'must-drop',
|
||||
appUserId: 'must-drop',
|
||||
handlerId: 'must-drop'
|
||||
}), {
|
||||
feedbackType: 'bug',
|
||||
feedbackContent: '页面按钮无响应',
|
||||
contactInfo: '19181970173'
|
||||
});
|
||||
assert.deepEqual(FeedbackPages.buildFeedbackBody({
|
||||
feedbackType: '',
|
||||
feedbackContent: ' 建议增加导出 ',
|
||||
contactInfo: ' '
|
||||
}), {
|
||||
feedbackContent: '建议增加导出'
|
||||
});
|
||||
});
|
||||
|
||||
test('反馈内容必填且类型只允许后端确认的四种值', () => {
|
||||
assert.equal(FeedbackPages.validateFeedbackBody({ feedbackContent: '建议增加导出' }), '');
|
||||
assert.equal(FeedbackPages.validateFeedbackBody({
|
||||
feedbackType: 'advice',
|
||||
feedbackContent: '建议增加导出'
|
||||
}), '');
|
||||
assert.match(FeedbackPages.validateFeedbackBody({ feedbackContent: '' }), /反馈内容/);
|
||||
assert.match(FeedbackPages.validateFeedbackBody({
|
||||
feedbackType: 'suggestion',
|
||||
feedbackContent: '建议增加导出'
|
||||
}), /反馈类型/);
|
||||
});
|
||||
|
||||
test('FeedbackVo 只保留用户可见字段并保持长 ID 字符串', () => {
|
||||
assert.deepEqual(FeedbackPages.normalizeFeedback(feedbackFixture()), {
|
||||
feedbackId,
|
||||
feedbackType: 'bug',
|
||||
feedbackContent: '页面按钮无响应',
|
||||
contactInfo: '19181970173',
|
||||
handleStatus: '2',
|
||||
handleResult: '已经修复',
|
||||
handleTime: '2026-07-29 13:00:00',
|
||||
status: '0',
|
||||
remark: '感谢反馈'
|
||||
});
|
||||
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
|
||||
feedbackId: Number.MAX_SAFE_INTEGER + 1
|
||||
})), null);
|
||||
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
|
||||
feedbackContent: ''
|
||||
})), null);
|
||||
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
|
||||
handleStatus: '9'
|
||||
})), null);
|
||||
assert.equal(FeedbackPages.normalizeFeedback(feedbackFixture({
|
||||
status: '9'
|
||||
})), null);
|
||||
});
|
||||
|
||||
test('反馈数组包含非法记录时整批拒绝', () => {
|
||||
assert.equal(FeedbackPages.normalizeFeedbackList([feedbackFixture()]).length, 1);
|
||||
assert.deepEqual(FeedbackPages.normalizeFeedbackList([
|
||||
feedbackFixture(),
|
||||
feedbackFixture({ feedbackId: '' })
|
||||
]), []);
|
||||
assert.deepEqual(FeedbackPages.normalizeFeedbackList({ rows: [feedbackFixture()] }), []);
|
||||
});
|
||||
|
||||
test('详情只精确匹配 URL 中的反馈 ID 且不存在时不回退', () => {
|
||||
const secondId = '2062179707935264770';
|
||||
const list = [feedbackFixture(), feedbackFixture({ feedbackId: secondId, feedbackContent: '第二条' })];
|
||||
|
||||
assert.equal(FeedbackPages.getFeedbackId('?feedbackId=' + secondId), secondId);
|
||||
assert.equal(FeedbackPages.getFeedbackId('?feedbackId=unsafe'), '');
|
||||
assert.equal(FeedbackPages.findFeedbackById(list, secondId).feedbackContent, '第二条');
|
||||
assert.equal(FeedbackPages.findFeedbackById(list, '2062179707935264999'), null);
|
||||
assert.equal(
|
||||
FeedbackPages.buildFeedbackDetailUrl(secondId),
|
||||
'ticket-detail.html?feedbackId=2062179707935264770'
|
||||
);
|
||||
});
|
||||
|
||||
test('反馈列表和详情转义文本且不暴露内部账号信息', () => {
|
||||
const item = feedbackFixture({
|
||||
feedbackContent: '<script>alert(1)</script>',
|
||||
handleResult: '<img src=x>',
|
||||
appUserId: 'secret-user',
|
||||
handlerId: 'secret-handler',
|
||||
appUserPhone: 'secret-account-phone'
|
||||
});
|
||||
const listHtml = FeedbackPages.renderFeedbackList([item], {
|
||||
detailPage: 'ticket-detail.html'
|
||||
});
|
||||
const detailHtml = FeedbackPages.renderFeedbackDetail(item);
|
||||
|
||||
assert.match(listHtml, /<script>/);
|
||||
assert.match(detailHtml, /<img src=x>/);
|
||||
assert.doesNotMatch(listHtml + detailHtml, /<script|<img|secret-user|secret-handler|secret-account-phone|appUserId|handlerId/);
|
||||
});
|
||||
|
||||
test('两个提交页只提供 AppFeedbackBody 字段并加载真实脚本', () => {
|
||||
['profile-feedback.html', 'submit-ticket.html'].forEach((page) => {
|
||||
const source = read(page);
|
||||
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(source, /data-feedback-form/);
|
||||
assert.match(source, /name="feedbackType"/);
|
||||
assert.match(source, /name="feedbackContent"/);
|
||||
assert.match(source, /name="contactInfo"/);
|
||||
assert.match(source, /public\/js\/feedback-pages\.js/);
|
||||
assert.doesNotMatch(source, /name="(?:feedbackTitle|feedbackId|appUserId|handlerId)"/);
|
||||
});
|
||||
});
|
||||
|
||||
test('我的工单和详情页读取同一反馈集合且不制造处理动作', () => {
|
||||
const listPage = read('my-tickets.html');
|
||||
const detailPage = read('ticket-detail.html');
|
||||
|
||||
[listPage, detailPage].forEach((source) => {
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(source, /public\/js\/feedback-pages\.js/);
|
||||
});
|
||||
assert.match(listPage, /data-feedback-list/);
|
||||
assert.match(listPage, /data-feedback-refresh/);
|
||||
assert.match(detailPage, /data-feedback-detail/);
|
||||
assert.doesNotMatch(detailPage, /回复|追问|删除|关闭工单|name="feedbackId"/);
|
||||
});
|
||||
|
||||
test('反馈运行时提交后重读同一 ID,详情不存在时不回退', () => {
|
||||
const source = read('public/js/feedback-pages.js');
|
||||
|
||||
assert.match(source, /await api\.submitFeedback\(/);
|
||||
assert.match(source, /await api\.myFeedback\(\)/);
|
||||
assert.match(source, /提交后无法读取同一反馈记录/);
|
||||
assert.match(source, /反馈记录不存在或无权查看/);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const HelpPages = require('../public/js/help-pages.js');
|
||||
|
||||
const helpId = '2062179707935264769';
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function helpFixture(overrides) {
|
||||
return Object.assign({
|
||||
helpId,
|
||||
helpCategory: '账号与安全',
|
||||
helpTitle: '如何修改登录密码?',
|
||||
helpContent: '进入账号安全页,完成验证后修改密码。',
|
||||
coverOssId: '2062179707935264701',
|
||||
sortOrder: 10,
|
||||
viewCount: 12,
|
||||
status: '0',
|
||||
remark: 'internal-only'
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('帮助文章只保留 HelpArticleVo 的安全展示字段并保持长 ID 字符串', () => {
|
||||
assert.deepEqual(HelpPages.normalizeHelpArticle(helpFixture()), {
|
||||
helpId,
|
||||
helpCategory: '账号与安全',
|
||||
helpTitle: '如何修改登录密码?',
|
||||
helpContent: '进入账号安全页,完成验证后修改密码。',
|
||||
viewCount: '12'
|
||||
});
|
||||
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({
|
||||
helpId: Number.MAX_SAFE_INTEGER + 1
|
||||
})), null);
|
||||
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({
|
||||
viewCount: '9007199254740993123'
|
||||
})).viewCount, '9007199254740993123');
|
||||
});
|
||||
|
||||
test('帮助文章拒绝停用、空标题、空正文和非法浏览量', () => {
|
||||
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ status: '1' })), null);
|
||||
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ helpTitle: ' ' })), null);
|
||||
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ helpContent: '' })), null);
|
||||
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ viewCount: -1 })), null);
|
||||
assert.equal(HelpPages.normalizeHelpArticle(helpFixture({ viewCount: '1.5' })), null);
|
||||
});
|
||||
|
||||
test('帮助文章列表只接受直接数组且任一非法元素使整批失败', () => {
|
||||
assert.equal(HelpPages.normalizeHelpArticleList([helpFixture()]).length, 1);
|
||||
assert.deepEqual(HelpPages.normalizeHelpArticleList([]), []);
|
||||
assert.deepEqual(HelpPages.normalizeHelpArticleList({
|
||||
rows: [helpFixture()]
|
||||
}), []);
|
||||
assert.deepEqual(HelpPages.normalizeHelpArticleList([
|
||||
helpFixture(),
|
||||
helpFixture({ helpId: '' })
|
||||
]), []);
|
||||
});
|
||||
|
||||
test('帮助文章列表和详情转义响应且不暴露 OSS ID、排序、状态和备注', () => {
|
||||
const article = helpFixture({
|
||||
helpTitle: '<script>alert(1)</script>',
|
||||
helpContent: '<img src=x onerror=alert(1)>\n第二行',
|
||||
helpCategory: '<b>分类</b>',
|
||||
coverOssId: 'secret-oss',
|
||||
remark: 'secret-remark'
|
||||
});
|
||||
const listHtml = HelpPages.renderHelpArticleList([article]);
|
||||
const detailHtml = HelpPages.renderHelpArticleDetail(article);
|
||||
|
||||
assert.match(listHtml, /<script>/);
|
||||
assert.match(detailHtml, /<img src=x onerror=alert\(1\)><br \/>第二行/);
|
||||
assert.doesNotMatch(listHtml + detailHtml, /<script|<img|secret-oss|secret-remark|coverOssId|sortOrder|status|remark/);
|
||||
});
|
||||
|
||||
test('帮助文章详情必须与列表中点击的真实 helpId 一致', async () => {
|
||||
const calls = [];
|
||||
const api = {
|
||||
async helpArticleDetail(id) {
|
||||
calls.push(id);
|
||||
return helpFixture();
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal((await HelpPages.loadHelpArticleDetail(api, helpId)).helpId, helpId);
|
||||
assert.deepEqual(calls, [helpId]);
|
||||
await assert.rejects(
|
||||
HelpPages.loadHelpArticleDetail({
|
||||
async helpArticleDetail() {
|
||||
return helpFixture({ helpId: '2062179707935264770' });
|
||||
}
|
||||
}, helpId),
|
||||
/详情响应与请求编号不一致/
|
||||
);
|
||||
});
|
||||
|
||||
test('帮助中心只把缺失登录态和 401 视为登录失效', () => {
|
||||
assert.equal(HelpPages.shouldRedirectToLogin(null), true);
|
||||
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return ''; } }), true);
|
||||
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 401 }), true);
|
||||
assert.equal(HelpPages.shouldRedirectToLogin({ getToken() { return 'token'; } }, { status: 403 }), false);
|
||||
});
|
||||
|
||||
test('帮助中心使用真实 PC 列表和详情,不再展示硬编码问答', () => {
|
||||
const page = read('help.html');
|
||||
|
||||
assert.match(page, /data-help-list/);
|
||||
assert.match(page, /data-help-status/);
|
||||
assert.match(page, /public\/js\/help-pages\.js/);
|
||||
assert.doesNotMatch(page, /如何创建第一本家谱|如何邀请亲人一起维护/);
|
||||
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const JoinPages = require('../public/js/join-pages.js');
|
||||
|
||||
const genealogyId = '2062179707935264769';
|
||||
const applyId = '2062179707935264770';
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('加入申请只构造 AppGenealogyJoinApplyBody 并裁剪可选文本', () => {
|
||||
assert.deepEqual(JoinPages.buildJoinApplyBody({
|
||||
applicantName: ' 叶子 ',
|
||||
phone: ' 19181970173 ',
|
||||
relationDesc: ' 族亲 ',
|
||||
applyReason: ' 申请加入 ',
|
||||
inviterUserId: 'must-drop',
|
||||
genealogyId: 'must-drop'
|
||||
}), {
|
||||
applicantName: '叶子',
|
||||
phone: '19181970173',
|
||||
relationDesc: '族亲',
|
||||
applyReason: '申请加入'
|
||||
});
|
||||
assert.deepEqual(JoinPages.buildJoinApplyBody({
|
||||
applicantName: ' ',
|
||||
phone: '',
|
||||
relationDesc: null
|
||||
}), {});
|
||||
});
|
||||
|
||||
test('加入申请按后端长度约束校验四个可选字段', () => {
|
||||
assert.equal(JoinPages.validateJoinApplyBody({ applicantName: '叶'.repeat(50) }), '');
|
||||
assert.match(JoinPages.validateJoinApplyBody({ applicantName: '叶'.repeat(51) }), /50/);
|
||||
assert.equal(JoinPages.validateJoinApplyBody({ phone: '1'.repeat(30) }), '');
|
||||
assert.match(JoinPages.validateJoinApplyBody({ phone: '1'.repeat(31) }), /30/);
|
||||
assert.equal(JoinPages.validateJoinApplyBody({ relationDesc: '亲'.repeat(100) }), '');
|
||||
assert.match(JoinPages.validateJoinApplyBody({ relationDesc: '亲'.repeat(101) }), /100/);
|
||||
assert.equal(JoinPages.validateJoinApplyBody({ applyReason: '因'.repeat(500) }), '');
|
||||
assert.match(JoinPages.validateJoinApplyBody({ applyReason: '因'.repeat(501) }), /500/);
|
||||
});
|
||||
|
||||
test('可申请家谱必须提供稳定字符串 ID、谱名和姓氏,并拒绝停用家谱', () => {
|
||||
assert.deepEqual(JoinPages.normalizeJoinGenealogy({
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
regionFullName: '四川省',
|
||||
memberCount: 12,
|
||||
joinMode: '0',
|
||||
status: '0',
|
||||
ownerUserId: 'must-drop'
|
||||
}), {
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
regionFullName: '四川省',
|
||||
memberCount: 12,
|
||||
joinMode: '0'
|
||||
});
|
||||
assert.equal(JoinPages.normalizeJoinGenealogy({
|
||||
genealogyId: Number.MAX_SAFE_INTEGER + 1,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
status: '0'
|
||||
}), null);
|
||||
assert.equal(JoinPages.normalizeJoinGenealogy({
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
status: '1'
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('我的申请只接收稳定 ID 和 0/1/2/3 状态', () => {
|
||||
assert.deepEqual(JoinPages.normalizeJoinApply({
|
||||
applyId,
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
relationDesc: '族亲',
|
||||
applyReason: '申请加入',
|
||||
auditRemark: '已核实',
|
||||
auditTime: '2026-07-29 12:00:00',
|
||||
status: '1',
|
||||
appUserId: 'must-drop',
|
||||
inviterUserId: 'must-drop',
|
||||
auditUserId: 'must-drop',
|
||||
appUserPhone: 'must-drop'
|
||||
}), {
|
||||
applyId,
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
relationDesc: '族亲',
|
||||
applyReason: '申请加入',
|
||||
auditRemark: '已核实',
|
||||
auditTime: '2026-07-29 12:00:00',
|
||||
status: '1'
|
||||
});
|
||||
assert.equal(JoinPages.normalizeJoinApply({
|
||||
applyId: Number.MAX_SAFE_INTEGER + 1,
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
status: '0'
|
||||
}), null);
|
||||
assert.equal(JoinPages.normalizeJoinApply({
|
||||
applyId,
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
status: '9'
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('申请数组只要包含非法元素就整批拒绝', () => {
|
||||
const valid = { applyId, genealogyId, genealogyName: '叶氏家谱', status: '0' };
|
||||
|
||||
assert.equal(JoinPages.normalizeJoinApplies([valid]).length, 1);
|
||||
assert.deepEqual(JoinPages.normalizeJoinApplies([valid, { status: '0' }]), []);
|
||||
assert.deepEqual(JoinPages.normalizeJoinApplies({ rows: [valid] }), []);
|
||||
});
|
||||
|
||||
test('普通用户列表隐藏内部字段且只有待审核申请可以撤销', () => {
|
||||
const html = JoinPages.renderMyJoinApplies([{
|
||||
applyId,
|
||||
genealogyId,
|
||||
genealogyName: '<叶氏家谱>',
|
||||
surname: '叶',
|
||||
relationDesc: '族亲',
|
||||
applyReason: '申请加入',
|
||||
status: '0',
|
||||
appUserId: 'secret-user',
|
||||
inviterUserId: 'secret-inviter',
|
||||
auditUserId: 'secret-auditor',
|
||||
appUserPhone: 'secret-phone'
|
||||
}]);
|
||||
const approvedHtml = JoinPages.renderMyJoinApplies([{
|
||||
applyId,
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
status: '1'
|
||||
}]);
|
||||
|
||||
assert.match(html, /<叶氏家谱>/);
|
||||
assert.match(html, new RegExp('data-join-cancel-id="' + applyId + '"'));
|
||||
assert.doesNotMatch(approvedHtml, /data-join-cancel-id/);
|
||||
assert.doesNotMatch(html, /secret-user|secret-inviter|secret-auditor|secret-phone|must-drop/);
|
||||
});
|
||||
|
||||
test('申请加入页由后端家谱选项驱动且不允许手填业务 ID 或邀请码', () => {
|
||||
const source = read('join-genealogy.html');
|
||||
|
||||
assert.match(source, /data-join-apply-page/);
|
||||
assert.match(source, /data-join-genealogy-search/);
|
||||
assert.match(source, /data-join-genealogy-options/);
|
||||
assert.match(source, /data-join-apply-form/);
|
||||
assert.match(source, /name="applicantName"/);
|
||||
assert.match(source, /name="phone"/);
|
||||
assert.match(source, /name="relationDesc"/);
|
||||
assert.match(source, /name="applyReason"/);
|
||||
assert.match(source, /public\/js\/join-pages\.js/);
|
||||
assert.doesNotMatch(source, /name="(?:genealogyId|applyId|inviterUserId|inviteCode)"/);
|
||||
assert.doesNotMatch(source, /邀请码|分享码|手工.*ID|输入.*ID/);
|
||||
});
|
||||
|
||||
test('我的加入申请页加载真实列表和刷新入口', () => {
|
||||
const source = read('profile-join-family.html');
|
||||
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(source, /data-my-join-applies-page/);
|
||||
assert.match(source, /data-join-apply-list="mine"/);
|
||||
assert.match(source, /data-join-apply-refresh/);
|
||||
assert.match(source, /public\/js\/join-pages\.js/);
|
||||
assert.doesNotMatch(source, /邀请码|分享码|输入.*ID/);
|
||||
});
|
||||
|
||||
test('加入申请运行时提交与撤销后都重新读取我的申请', () => {
|
||||
const source = read('public/js/join-pages.js');
|
||||
|
||||
assert.match(source, /await api\.applyToGenealogy\(/);
|
||||
assert.match(source, /await api\.myGenealogyJoinApplies\(\)/);
|
||||
assert.match(source, /await api\.cancelGenealogyJoinApply\(/);
|
||||
assert.match(source, /提交后无法读取同一申请/);
|
||||
assert.match(source, /撤销后申请仍处于待审核状态/);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const JoinReviewPages = require('../public/js/join-review-pages.js');
|
||||
|
||||
const genealogyId = '2062179707935264769';
|
||||
const applyId = '2062179707935264770';
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('审核页只从当前家谱上下文读取稳定字符串 ID', () => {
|
||||
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=' + genealogyId), genealogyId);
|
||||
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=unsafe'), '');
|
||||
assert.equal(JoinReviewPages.getCurrentGenealogyId('?genealogyId=1e21'), '');
|
||||
});
|
||||
|
||||
test('审核请求只构造 status 和可选审核说明', () => {
|
||||
assert.deepEqual(JoinReviewPages.buildJoinAuditBody({
|
||||
status: '2',
|
||||
auditRemark: ' 资料不一致 ',
|
||||
applyId: 'must-drop',
|
||||
genealogyId: 'must-drop'
|
||||
}), {
|
||||
status: '2',
|
||||
auditRemark: '资料不一致'
|
||||
});
|
||||
assert.deepEqual(JoinReviewPages.buildJoinAuditBody({ status: '1', auditRemark: ' ' }), {
|
||||
status: '1'
|
||||
});
|
||||
assert.equal(JoinReviewPages.validateJoinAuditBody({ status: '1' }), '');
|
||||
assert.equal(JoinReviewPages.validateJoinAuditBody({ status: '2', auditRemark: '因'.repeat(500) }), '');
|
||||
assert.match(JoinReviewPages.validateJoinAuditBody({ status: '0' }), /审核状态/);
|
||||
assert.match(JoinReviewPages.validateJoinAuditBody({ status: '2', auditRemark: '因'.repeat(501) }), /500/);
|
||||
});
|
||||
|
||||
test('待审核列表隐藏内部 ID 和账号手机号,只有管理者可见操作', () => {
|
||||
const data = [{
|
||||
applyId,
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
applicantName: '<叶子>',
|
||||
phone: '19181970173',
|
||||
relationDesc: '族亲',
|
||||
applyReason: '申请加入',
|
||||
status: '0',
|
||||
appUserId: 'secret-user',
|
||||
appUserPhone: 'secret-account-phone',
|
||||
inviterUserId: 'secret-inviter',
|
||||
auditUserId: 'secret-auditor'
|
||||
}];
|
||||
const managerHtml = JoinReviewPages.renderPendingJoinApplies(data, true);
|
||||
const memberHtml = JoinReviewPages.renderPendingJoinApplies(data, false);
|
||||
|
||||
assert.match(managerHtml, /<叶子>/);
|
||||
assert.match(managerHtml, /19181970173/);
|
||||
assert.match(managerHtml, new RegExp('data-join-audit-id="' + applyId + '"'));
|
||||
assert.match(managerHtml, /data-join-audit-status="1"/);
|
||||
assert.match(managerHtml, /data-join-audit-status="2"/);
|
||||
assert.doesNotMatch(memberHtml, /data-join-audit-id|data-join-audit-status/);
|
||||
assert.doesNotMatch(managerHtml, /secret-user|secret-account-phone|secret-inviter|secret-auditor/);
|
||||
});
|
||||
|
||||
test('审核页开放真实 PC 管理行为且不允许手填业务 ID', () => {
|
||||
const page = read('profile-join-review.html');
|
||||
const script = read('public/js/join-review-pages.js');
|
||||
|
||||
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(page, /data-join-review-page/);
|
||||
assert.match(page, /data-join-apply-list="pending"/);
|
||||
assert.match(page, /data-join-review-refresh/);
|
||||
assert.match(page, /public\/js\/join-review-pages\.js/);
|
||||
assert.doesNotMatch(page, /name="(?:genealogyId|applyId|appUserId|inviterUserId|auditUserId)"/);
|
||||
assert.match(script, /await api\.genealogyDetail\(/);
|
||||
assert.match(script, /detail\.canManage === true/);
|
||||
assert.match(script, /await api\.auditGenealogyJoinApply\(/);
|
||||
assert.match(script, /await api\.pendingGenealogyJoinApplies\(/);
|
||||
assert.match(script, /审核后申请仍在待审核列表/);
|
||||
});
|
||||
@@ -10,6 +10,22 @@ test('世系页只从 URL 读取真实家谱编号', () => {
|
||||
assert.equal(LineagePages.getCurrentGenealogyId(''), '');
|
||||
});
|
||||
|
||||
test('世系管理页和家谱主页复用同一棵安全树的 HTML', () => {
|
||||
const html = LineagePages.renderLineageTreeHtml([{
|
||||
personId: '2062179707935264770',
|
||||
genealogyId: '2062179707935264769',
|
||||
name: '<始祖>',
|
||||
generationName: '始',
|
||||
spouses: [],
|
||||
children: []
|
||||
}]);
|
||||
|
||||
assert.match(html, /<始祖>/);
|
||||
assert.match(html, /data-lineage-person="2062179707935264770"/);
|
||||
assert.doesNotMatch(html, /2062179707935264769/);
|
||||
assert.match(LineagePages.renderLineageTreeHtml([]), /暂无世系树/);
|
||||
});
|
||||
|
||||
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
|
||||
assert.deepEqual(
|
||||
LineagePages.buildLineagePersonBody({
|
||||
|
||||
+15
-3
@@ -36,9 +36,9 @@ test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
|
||||
|
||||
test('pages do not load scripts for unavailable business APIs', () => {
|
||||
const removedScripts = [
|
||||
'feedback-pages.js', 'genealogy-pages.js',
|
||||
'help-pages.js', 'join-apply-pages.js',
|
||||
'promotion-pages.js', 'vip-pages.js'
|
||||
'genealogy-pages.js',
|
||||
'join-apply-pages.js',
|
||||
'promotion-pages.js'
|
||||
];
|
||||
const retainedPages = fs.readdirSync(root).filter((entry) => entry.endsWith('.html'));
|
||||
|
||||
@@ -53,6 +53,17 @@ test('pages do not load scripts for unavailable business APIs', () => {
|
||||
assert.equal(read(page).includes('src="public/js/feed-pages.js"'), true, `${page} must load feed-pages.js`);
|
||||
});
|
||||
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('join-genealogy.html').includes('src="public/js/join-pages.js"'), true, 'join-genealogy.html must load join-pages.js');
|
||||
assert.equal(read('profile-join-family.html').includes('src="public/js/join-pages.js"'), true, 'profile-join-family.html must load join-pages.js');
|
||||
assert.equal(read('profile-join-review.html').includes('src="public/js/join-review-pages.js"'), true, 'profile-join-review.html must load join-review-pages.js');
|
||||
['profile-feedback.html', 'submit-ticket.html', 'my-tickets.html', 'ticket-detail.html'].forEach((page) => {
|
||||
assert.equal(read(page).includes('src="public/js/feedback-pages.js"'), true, `${page} must load feedback-pages.js`);
|
||||
});
|
||||
assert.equal(read('help.html').includes('src="public/js/help-pages.js"'), true, 'help.html must load help-pages.js');
|
||||
assert.equal(read('app.html').includes('src="public/js/app-promotion-pages.js"'), true, 'app.html must load app-promotion-pages.js');
|
||||
['news.html', 'article-detail.html'].forEach((page) => {
|
||||
assert.equal(read(page).includes('src="public/js/site-news-pages.js"'), true, `${page} must load site-news-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');
|
||||
@@ -60,4 +71,5 @@ test('pages do not load scripts for unavailable business APIs', () => {
|
||||
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');
|
||||
assert.equal(read('profile-services.html').includes('src="public/js/vip-pages.js"'), true, 'profile-services.html must load vip-pages.js');
|
||||
});
|
||||
|
||||
@@ -7,21 +7,16 @@ const PageAvailability = require('../public/js/pending-pages.js');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const familyPendingPages = [
|
||||
'profile-join-family.html',
|
||||
'profile-join-review.html',
|
||||
'profile-invite.html',
|
||||
];
|
||||
|
||||
const contentPendingPages = [
|
||||
'profile-feedback.html',
|
||||
'profile-admin-permissions.html',
|
||||
'profile-data-reminders.html'
|
||||
];
|
||||
|
||||
const residualPendingPages = [
|
||||
'profile-family-home.html',
|
||||
'profile-share.html',
|
||||
'profile-services.html'
|
||||
'profile-share.html'
|
||||
];
|
||||
|
||||
const pendingPages = familyPendingPages.concat(contentPendingPages, residualPendingPages);
|
||||
@@ -53,12 +48,17 @@ test('待开发页面显式加载状态脚本', () => {
|
||||
});
|
||||
|
||||
test('已接入的家谱业务页面保持真实功能状态', () => {
|
||||
['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) => {
|
||||
['profile-families.html', 'profile-create-family.html', 'profile-join-family.html', 'profile-join-review.html', 'profile-family-home.html', 'profile-family-admin.html', 'profile-feedback.html', 'profile-services.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-join-family.html'), /src="public\/js\/join-pages\.js"/);
|
||||
assert.match(read('profile-join-review.html'), /src="public\/js\/join-review-pages\.js"/);
|
||||
assert.match(read('profile-feedback.html'), /src="public\/js\/feedback-pages\.js"/);
|
||||
assert.match(read('profile-services.html'), /src="public\/js\/vip-pages\.js"/);
|
||||
assert.match(read('profile-family-home.html'), /src="public\/js\/family-home-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"/);
|
||||
@@ -93,7 +93,7 @@ test('待开发的家谱管理页明确放行已接入的字辈入口并透传
|
||||
});
|
||||
|
||||
test('待开发页面仅放行显式标记的可用功能入口', () => {
|
||||
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feedback.html' })), true);
|
||||
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-share.html' })), true);
|
||||
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feed.html', 'data-feature-link': 'available' })), false);
|
||||
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: '#section' })), false);
|
||||
});
|
||||
|
||||
@@ -37,14 +37,16 @@ test('官网静态信息页面均有独立入口', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('新闻页提供分类与现有详情跳转', () => {
|
||||
test('新闻页提供真实 PC 文章分类与详情入口', () => {
|
||||
if (!exists('news.html')) return;
|
||||
|
||||
const source = read('news.html');
|
||||
assert.match(source, /href="#platform"/);
|
||||
assert.match(source, /href="#culture"/);
|
||||
assert.match(source, /href="notice-detail\.html"/);
|
||||
assert.match(source, /href="article-detail\.html"/);
|
||||
assert.match(source, /href="news\.html"/);
|
||||
assert.match(source, /href="news\.html\?articleType=news"/);
|
||||
assert.match(source, /href="news\.html\?articleType=notice"/);
|
||||
assert.match(source, /href="news\.html\?articleType=download"/);
|
||||
assert.match(source, /data-site-news-list/);
|
||||
assert.match(source, /src="public\/js\/site-news-pages\.js"/);
|
||||
});
|
||||
|
||||
test('法律页面明确等待法务确认', () => {
|
||||
@@ -54,14 +56,14 @@ test('法律页面明确等待法务确认', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('工单功能入口显示待开发状态并保留帮助中心跳转', () => {
|
||||
test('工单功能入口开放真实反馈记录并保留帮助中心跳转', () => {
|
||||
['submit-ticket.html', 'my-tickets.html', 'ticket-detail.html'].forEach((page) => {
|
||||
if (!exists(page)) return;
|
||||
const source = read(page);
|
||||
|
||||
assert.match(source, /data-feature-status="pending"/);
|
||||
assert.match(source, /src="public\/js\/pending-pages\.js"/);
|
||||
assert.match(source, /href="help\.html"\s+data-feature-link="available"/);
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
|
||||
assert.match(source, /src="public\/js\/feedback-pages\.js"/);
|
||||
assert.match(source, /href="help\.html"/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const GenealogyApi = require('../utils/ApiClient.js');
|
||||
const AxiosRequestUtil = require('../utils/AxiosRequestUtil.js');
|
||||
|
||||
function createFailingClient(status) {
|
||||
let storedToken = 'access-token';
|
||||
@@ -96,3 +97,64 @@ test('business response code 401 clears the stored login token', async () => {
|
||||
|
||||
assert.equal(client.getToken(), '');
|
||||
});
|
||||
|
||||
test('public HTTP 401 neither sends nor clears the stored login token', async () => {
|
||||
let storedToken = 'access-token';
|
||||
let seenRequest;
|
||||
const requester = AxiosRequestUtil.createRequester({
|
||||
baseUrl: 'https://api.example.test',
|
||||
clientId: 'web-pc',
|
||||
getToken() {
|
||||
return storedToken;
|
||||
},
|
||||
onUnauthorized() {
|
||||
storedToken = '';
|
||||
},
|
||||
axiosInstance: {
|
||||
request(config) {
|
||||
seenRequest = config;
|
||||
return Promise.reject({
|
||||
message: 'Request failed',
|
||||
response: {
|
||||
status: 401,
|
||||
data: {
|
||||
code: 401,
|
||||
msg: '认证失败'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(requester('GET', '/public', { auth: false }));
|
||||
assert.equal(seenRequest.headers.Authorization, undefined);
|
||||
assert.equal(storedToken, 'access-token');
|
||||
});
|
||||
|
||||
test('public business 401 preserves the stored login token', async () => {
|
||||
let storedToken = 'access-token';
|
||||
const requester = AxiosRequestUtil.createRequester({
|
||||
baseUrl: 'https://api.example.test',
|
||||
clientId: 'web-pc',
|
||||
getToken() {
|
||||
return storedToken;
|
||||
},
|
||||
onUnauthorized() {
|
||||
storedToken = '';
|
||||
},
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
code: 401,
|
||||
msg: '认证失败'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(requester('GET', '/public', { auth: false }));
|
||||
assert.equal(storedToken, 'access-token');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const SiteNewsPages = require('../public/js/site-news-pages.js');
|
||||
const newsPage = fs.readFileSync(path.resolve(__dirname, '../news.html'), 'utf8');
|
||||
const detailPage = fs.readFileSync(path.resolve(__dirname, '../article-detail.html'), 'utf8');
|
||||
|
||||
const articleId = '2062179707935264769';
|
||||
|
||||
function articleFixture(overrides) {
|
||||
return Object.assign({
|
||||
articleId,
|
||||
articleType: 'notice',
|
||||
articleTitle: '平台公告',
|
||||
articleSummary: '公告摘要',
|
||||
articleContent: '第一行\n第二行',
|
||||
coverOssId: '2062179707935264701',
|
||||
externalUrl: 'https://example.com/notice',
|
||||
publishTime: '2026-07-30 09:00:00',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: 'internal-only'
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('站点文章只保留完整 SiteArticleVo 的安全展示字段和字符串长 ID', () => {
|
||||
assert.deepEqual(SiteNewsPages.normalizeArticle(articleFixture()), {
|
||||
articleId,
|
||||
articleType: 'notice',
|
||||
articleTitle: '平台公告',
|
||||
articleSummary: '公告摘要',
|
||||
articleContent: '第一行\n第二行',
|
||||
externalUrl: 'https://example.com/notice',
|
||||
publishTime: '2026-07-30 09:00:00'
|
||||
});
|
||||
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({
|
||||
articleId: Number.MAX_SAFE_INTEGER + 1
|
||||
})), null);
|
||||
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleId: '0' })), null);
|
||||
});
|
||||
|
||||
test('站点文章拒绝 SQL 字典外类型、停用记录和空标题', () => {
|
||||
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleType: 'culture' })), null);
|
||||
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ status: '1' })), null);
|
||||
assert.equal(SiteNewsPages.normalizeArticle(articleFixture({ articleTitle: ' ' })), null);
|
||||
assert.equal(SiteNewsPages.normalizeArticleType('news'), 'news');
|
||||
assert.equal(SiteNewsPages.normalizeArticleType('notice'), 'notice');
|
||||
assert.equal(SiteNewsPages.normalizeArticleType('download'), 'download');
|
||||
assert.equal(SiteNewsPages.normalizeArticleType('culture'), '');
|
||||
});
|
||||
|
||||
test('站点文章列表只接受直接数组且任一非法元素使整批失败', () => {
|
||||
assert.equal(SiteNewsPages.normalizeArticleList([articleFixture()]).length, 1);
|
||||
assert.deepEqual(SiteNewsPages.normalizeArticleList([]), []);
|
||||
assert.deepEqual(SiteNewsPages.normalizeArticleList({ rows: [articleFixture()] }), []);
|
||||
assert.deepEqual(SiteNewsPages.normalizeArticleList([
|
||||
articleFixture(),
|
||||
articleFixture({ articleId: '' })
|
||||
]), []);
|
||||
});
|
||||
|
||||
test('站点资讯列表使用响应 ID 生成详情链接并隐藏内部字段', () => {
|
||||
const html = SiteNewsPages.renderArticleList([articleFixture({
|
||||
articleTitle: '<script>alert(1)</script>',
|
||||
articleSummary: '<img src=x>',
|
||||
coverOssId: 'secret-oss',
|
||||
remark: 'secret-remark'
|
||||
})]);
|
||||
|
||||
assert.match(html, /article-detail\.html\?articleId=2062179707935264769/);
|
||||
assert.match(html, /公告/);
|
||||
assert.match(html, /<script>/);
|
||||
assert.match(html, /<img src=x>/);
|
||||
assert.doesNotMatch(html, /<script|<img|secret-oss|secret-remark|coverOssId|sortOrder|status|remark/);
|
||||
assert.match(SiteNewsPages.renderArticleList([]), /当前暂无资讯/);
|
||||
});
|
||||
|
||||
test('站点资讯详情转义正文、保留换行并只开放安全外链', () => {
|
||||
const html = SiteNewsPages.renderArticleDetail(articleFixture({
|
||||
articleContent: '<script>alert(1)</script>\n第二行'
|
||||
}));
|
||||
|
||||
assert.match(html, /<script>alert\(1\)<\/script><br \/>第二行/);
|
||||
assert.match(html, /href="https:\/\/example\.com\/notice"/);
|
||||
assert.match(html, /target="_blank"/);
|
||||
assert.match(html, /rel="noopener noreferrer"/);
|
||||
assert.doesNotMatch(html, /<script|coverOssId|secret-remark/);
|
||||
|
||||
['javascript:alert(1)', 'data:text/html,test', '/notice', 'notice.html', ''].forEach((value) => {
|
||||
const unsafeHtml = SiteNewsPages.renderArticleDetail(articleFixture({ externalUrl: value }));
|
||||
assert.doesNotMatch(unsafeHtml, /data-external-link/);
|
||||
});
|
||||
});
|
||||
|
||||
test('站点文章外链只接受绝对 HTTP 或 HTTPS', () => {
|
||||
assert.equal(
|
||||
SiteNewsPages.normalizeExternalUrl(' https://example.com/notice '),
|
||||
'https://example.com/notice'
|
||||
);
|
||||
assert.equal(SiteNewsPages.normalizeExternalUrl('http://example.com'), 'http://example.com/');
|
||||
assert.equal(SiteNewsPages.normalizeExternalUrl('javascript:alert(1)'), '');
|
||||
assert.equal(SiteNewsPages.normalizeExternalUrl('/notice'), '');
|
||||
});
|
||||
|
||||
test('资讯列表只提交已确认分类和系统 limit', async () => {
|
||||
const calls = [];
|
||||
const api = {
|
||||
async siteArticles(query) {
|
||||
calls.push(query);
|
||||
return [articleFixture()];
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal((await SiteNewsPages.loadArticles(api, 'notice')).length, 1);
|
||||
assert.equal((await SiteNewsPages.loadArticles(api, '')).length, 1);
|
||||
assert.deepEqual(calls, [
|
||||
{ articleType: 'notice', limit: 100 },
|
||||
{ limit: 100 }
|
||||
]);
|
||||
await assert.rejects(SiteNewsPages.loadArticles(api, 'culture'), /资讯分类无效/);
|
||||
await assert.rejects(
|
||||
SiteNewsPages.loadArticles({
|
||||
async siteArticles() {
|
||||
return { rows: [articleFixture()] };
|
||||
}
|
||||
}, ''),
|
||||
/资讯列表响应无效/
|
||||
);
|
||||
});
|
||||
|
||||
test('资讯详情只精确匹配 URL 中由列表产生的文章 ID', async () => {
|
||||
const api = {
|
||||
async siteArticles(query) {
|
||||
assert.deepEqual(query, { limit: 100 });
|
||||
return [
|
||||
articleFixture(),
|
||||
articleFixture({ articleId: '2062179707935264770', articleTitle: '另一条' })
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
assert.equal((await SiteNewsPages.loadArticleDetail(api, articleId)).articleTitle, '平台公告');
|
||||
await assert.rejects(
|
||||
SiteNewsPages.loadArticleDetail(api, '2062179707935264771'),
|
||||
/资讯不存在或已下线/
|
||||
);
|
||||
await assert.rejects(
|
||||
SiteNewsPages.loadArticleDetail(api, Number.MAX_SAFE_INTEGER + 1),
|
||||
/资讯编号无效/
|
||||
);
|
||||
});
|
||||
|
||||
test('详情 URL 只读取安全字符串文章 ID', () => {
|
||||
assert.equal(SiteNewsPages.readArticleId('?articleId=' + articleId), articleId);
|
||||
assert.equal(SiteNewsPages.readArticleId('?articleId=0'), '');
|
||||
assert.equal(SiteNewsPages.readArticleId('?articleId=unsafe'), '');
|
||||
assert.equal(SiteNewsPages.readArticleId(''), '');
|
||||
});
|
||||
|
||||
test('官网资讯列表与详情页加载同一个公开内容 owner 且不暴露业务 ID', () => {
|
||||
assert.match(newsPage, /<body[^>]*data-site-news-page/);
|
||||
assert.match(newsPage, /data-site-news-list/);
|
||||
assert.match(newsPage, /data-site-news-status/);
|
||||
assert.match(newsPage, /news\.html\?articleType=news/);
|
||||
assert.match(newsPage, /news\.html\?articleType=notice/);
|
||||
assert.match(newsPage, /news\.html\?articleType=download/);
|
||||
assert.match(newsPage, /src="public\/js\/site-news-pages\.js"/);
|
||||
|
||||
assert.match(detailPage, /<body[^>]*data-site-article-page/);
|
||||
assert.match(detailPage, /<title>资讯详情 - 代代相传<\/title>/);
|
||||
assert.match(detailPage, /data-site-article-detail/);
|
||||
assert.match(detailPage, /data-site-article-status/);
|
||||
assert.match(detailPage, /src="public\/js\/site-news-pages\.js"/);
|
||||
assert.doesNotMatch(detailPage, /src="public\/js\/article-pages\.js"/);
|
||||
assert.doesNotMatch(detailPage, /href="article-detail\.html"/);
|
||||
assert.doesNotMatch(newsPage + detailPage, /name="(?:articleId|coverOssId)"/);
|
||||
});
|
||||
@@ -33,3 +33,24 @@ test('个人中心功能入口没有把家谱业务页当作无上下文普通
|
||||
assert.match(page, new RegExp('href="' + href.replace('.', '\\.') + '"[^>]*data-genealogy-context-link'), `${href} 缺少个人中心上下文入口`);
|
||||
});
|
||||
});
|
||||
|
||||
test('加入申请与管理员审核入口使用真实页面和家谱上下文', () => {
|
||||
const families = read('profile-families.html');
|
||||
const familyAdmin = read('profile-family-admin.html');
|
||||
const profile = read('profile.html');
|
||||
|
||||
assert.match(families, /href="profile-join-family\.html"/);
|
||||
assert.doesNotMatch(families, /邀请码|分享码|手填.*ID/);
|
||||
assert.match(familyAdmin, /href="profile-join-review\.html"[^>]*data-genealogy-context-link/);
|
||||
assert.match(profile, /href="profile-join-review\.html"[^>]*data-genealogy-context-link/);
|
||||
});
|
||||
|
||||
test('帮助中心和服务中心开放同一反馈记录闭环', () => {
|
||||
const help = read('help.html');
|
||||
const services = read('profile-services.html');
|
||||
|
||||
assert.match(help, /href="submit-ticket\.html"/);
|
||||
assert.match(help, /href="my-tickets\.html"/);
|
||||
assert.match(services, /href="profile-feedback\.html"[^>]*data-feature-link="available"/);
|
||||
assert.doesNotMatch(help + services, /手填.*feedbackId|独立.*ticket.*接口/i);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const VipPages = require('../public/js/vip-pages.js');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
const packageId = '2062179707935264769';
|
||||
const genealogyId = '2062179707935264770';
|
||||
const orderId = '2062179707935264771';
|
||||
|
||||
function packageFixture(overrides) {
|
||||
return Object.assign({
|
||||
packageId,
|
||||
packageName: '家谱年度会员',
|
||||
packageType: 'vip',
|
||||
packageDesc: '适合持续维护家谱',
|
||||
price: '99.00',
|
||||
originalPrice: '129.00',
|
||||
durationValue: 1,
|
||||
durationUnit: 'year',
|
||||
genealogyLimit: 3,
|
||||
memberLimit: 500,
|
||||
storageLimitMb: 10240,
|
||||
featureJson: '{"export":true}',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: '年度套餐'
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
function orderFixture(overrides) {
|
||||
return Object.assign({
|
||||
orderId,
|
||||
orderNo: 'VIP202607291200001234',
|
||||
packageId,
|
||||
packageName: '家谱年度会员',
|
||||
appUserId: '2062179707935264701',
|
||||
appUserNickName: '叶子',
|
||||
appUserPhone: '19100000000',
|
||||
genealogyId,
|
||||
genealogyNo: 'G20260729001',
|
||||
genealogyName: '叶氏家谱',
|
||||
orderAmount: '99.00',
|
||||
payAmount: '99.00',
|
||||
payType: 'wechat',
|
||||
payStatus: '0',
|
||||
payTime: null,
|
||||
expireTime: null,
|
||||
status: '0',
|
||||
remark: ''
|
||||
}, overrides);
|
||||
}
|
||||
|
||||
test('VIP 套餐只保留可展示字段并保持金额和长 ID 字符串', () => {
|
||||
assert.deepEqual(VipPages.normalizeVipPackage(packageFixture()), {
|
||||
packageId,
|
||||
packageName: '家谱年度会员',
|
||||
packageType: 'vip',
|
||||
packageDesc: '适合持续维护家谱',
|
||||
price: '99.00',
|
||||
originalPrice: '129.00',
|
||||
durationValue: 1,
|
||||
durationUnit: 'year',
|
||||
genealogyLimit: 3,
|
||||
memberLimit: 500,
|
||||
storageLimitMb: 10240,
|
||||
remark: '年度套餐'
|
||||
});
|
||||
assert.equal(VipPages.normalizeVipPackage(packageFixture({
|
||||
packageId: Number.MAX_SAFE_INTEGER + 1
|
||||
})), null);
|
||||
assert.equal(VipPages.normalizeVipPackage(packageFixture({
|
||||
packageType: 'unknown'
|
||||
})), null);
|
||||
assert.equal(VipPages.normalizeVipPackage(packageFixture({
|
||||
durationUnit: 'week'
|
||||
})), null);
|
||||
assert.equal(VipPages.normalizeVipPackage(packageFixture({
|
||||
price: '-1.00'
|
||||
})), null);
|
||||
assert.equal(VipPages.normalizeVipPackage(packageFixture({
|
||||
status: '1'
|
||||
})), null);
|
||||
});
|
||||
|
||||
test('套餐列表过滤停用或非法记录', () => {
|
||||
assert.equal(VipPages.normalizeVipPackages([
|
||||
packageFixture(),
|
||||
packageFixture({ packageId: '2062179707935264772', status: '1' })
|
||||
]).length, 1);
|
||||
assert.deepEqual(VipPages.normalizeVipPackages({ rows: [packageFixture()] }), []);
|
||||
});
|
||||
|
||||
test('家谱选择项只接受响应中的稳定 ID 和名称', () => {
|
||||
assert.deepEqual(VipPages.normalizeGenealogyOption({
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶',
|
||||
ownerUserId: 'must-drop'
|
||||
}), {
|
||||
genealogyId,
|
||||
genealogyName: '叶氏家谱',
|
||||
surname: '叶'
|
||||
});
|
||||
assert.equal(VipPages.normalizeGenealogyOption({
|
||||
genealogyId: Number.MAX_SAFE_INTEGER + 1,
|
||||
genealogyName: '叶氏家谱'
|
||||
}), null);
|
||||
assert.equal(VipPages.normalizeGenealogyOption({
|
||||
genealogyId,
|
||||
genealogyName: ''
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('订单请求只构造 AppVipOrderBody 且页面默认省略支付方式', () => {
|
||||
assert.deepEqual(VipPages.buildVipOrderBody({
|
||||
packageId: ' ' + packageId + ' ',
|
||||
genealogyId: ' ' + genealogyId + ' ',
|
||||
payType: '',
|
||||
orderId: 'must-drop',
|
||||
appUserId: 'must-drop',
|
||||
payStatus: 'must-drop'
|
||||
}), {
|
||||
packageId,
|
||||
genealogyId
|
||||
});
|
||||
assert.deepEqual(VipPages.buildVipOrderBody({
|
||||
packageId,
|
||||
payType: ' wechat '
|
||||
}), {
|
||||
packageId,
|
||||
payType: 'wechat'
|
||||
});
|
||||
});
|
||||
|
||||
test('订单请求要求安全套餐 ID,并只接受已确认的可选值', () => {
|
||||
assert.equal(VipPages.validateVipOrderBody({ packageId }), '');
|
||||
assert.equal(VipPages.validateVipOrderBody({ packageId, genealogyId }), '');
|
||||
assert.match(VipPages.validateVipOrderBody({ packageId: '' }), /套餐/);
|
||||
assert.match(VipPages.validateVipOrderBody({
|
||||
packageId,
|
||||
genealogyId: 'unsafe'
|
||||
}), /家谱/);
|
||||
assert.match(VipPages.validateVipOrderBody({
|
||||
packageId,
|
||||
payType: 'alipay'
|
||||
}), /支付方式/);
|
||||
});
|
||||
|
||||
test('VIP 订单隐藏账号字段并校验金额和状态', () => {
|
||||
assert.deepEqual(VipPages.normalizeVipOrder(orderFixture()), {
|
||||
orderId,
|
||||
orderNo: 'VIP202607291200001234',
|
||||
packageId,
|
||||
packageName: '家谱年度会员',
|
||||
genealogyId,
|
||||
genealogyNo: 'G20260729001',
|
||||
genealogyName: '叶氏家谱',
|
||||
orderAmount: '99.00',
|
||||
payAmount: '99.00',
|
||||
payType: 'wechat',
|
||||
payStatus: '0',
|
||||
payTime: '',
|
||||
expireTime: '',
|
||||
status: '0',
|
||||
remark: ''
|
||||
});
|
||||
assert.equal(VipPages.normalizeVipOrder(orderFixture({
|
||||
orderId: Number.MAX_SAFE_INTEGER + 1
|
||||
})), null);
|
||||
assert.equal(VipPages.normalizeVipOrder(orderFixture({
|
||||
payAmount: '-1'
|
||||
})), null);
|
||||
assert.equal(VipPages.normalizeVipOrder(orderFixture({
|
||||
payStatus: '9'
|
||||
})), null);
|
||||
assert.equal(VipPages.normalizeVipOrder(orderFixture({
|
||||
status: '9'
|
||||
})), null);
|
||||
});
|
||||
|
||||
test('套餐和订单渲染不出现内部账号信息或伪支付操作', () => {
|
||||
const packagesHtml = VipPages.renderVipPackages([packageFixture({
|
||||
packageName: '<年度会员>'
|
||||
})], packageId);
|
||||
const ordersHtml = VipPages.renderVipOrders([orderFixture({
|
||||
appUserId: 'secret-user',
|
||||
appUserPhone: 'secret-phone'
|
||||
})]);
|
||||
const combined = packagesHtml + ordersHtml;
|
||||
|
||||
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|立即支付|模拟|取消订单|退款/);
|
||||
});
|
||||
|
||||
test('套餐渲染接受初始化流程已经规范化的数据', () => {
|
||||
const normalized = VipPages.normalizeVipPackages([packageFixture()]);
|
||||
const html = VipPages.renderVipPackages(normalized, packageId);
|
||||
|
||||
assert.match(html, /家谱年度会员/);
|
||||
assert.match(html, /data-vip-package-id=/);
|
||||
assert.doesNotMatch(html, /当前没有可用会员套餐/);
|
||||
});
|
||||
|
||||
test('会员服务页只从接口结果选择套餐和家谱,不暴露业务 ID 或未开放支付操作', () => {
|
||||
const source = fs.readFileSync(path.join(root, 'profile-services.html'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(source, /data-feature-status="pending"/);
|
||||
assert.match(source, /src="public\/js\/vip-pages\.js"/);
|
||||
assert.match(source, /data-vip-package-list/);
|
||||
assert.match(source, /data-vip-selected-package/);
|
||||
assert.match(source, /<select[^>]+data-vip-genealogy-options/);
|
||||
assert.match(source, /data-vip-order-refresh/);
|
||||
assert.match(source, /data-vip-order-list/);
|
||||
assert.doesNotMatch(source, /<input[^>]+name="(?:packageId|genealogyId|orderId)"/);
|
||||
assert.doesNotMatch(source, /name="payType"/);
|
||||
assert.doesNotMatch(source, /立即支付|模拟支付|取消订单|退款/);
|
||||
});
|
||||
|
||||
test('会员服务运行时并行读取套餐、我的家谱和订单,并在创建后重新读取核验', () => {
|
||||
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, /refreshed = normalizeVipOrders\(await api\.vipOrders\(\)\)/);
|
||||
assert.match(source, /item\.orderId === created\.orderId/);
|
||||
});
|
||||
Reference in New Issue
Block a user