等待后端更新接口

This commit is contained in:
2026-08-03 16:49:26 +08:00
parent ac5823547a
commit 5b79893650
131 changed files with 5324 additions and 1392 deletions
+84
View File
@@ -0,0 +1,84 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const AdminPermissionPages = require('../public/js/admin-permission-pages.js');
const root = path.resolve(__dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
test('管理员权限页只从当前家谱上下文读取安全编号', () => {
assert.equal(AdminPermissionPages.getCurrentGenealogyId('?genealogyId=2062179707935264769'), '2062179707935264769');
assert.equal(AdminPermissionPages.getCurrentGenealogyId('?genealogyId=0'), '');
assert.equal(AdminPermissionPages.getCurrentGenealogyId('?genealogyId=unsafe'), '');
});
test('管理员权限概览只接受当前家谱的角色与严格布尔能力字段', () => {
assert.deepEqual(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: true,
canEditContent: false,
ownerUserId: '2062179707935264701'
}, '2062179707935264769'), {
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: true,
canEditContent: false
});
assert.equal(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264768',
roleType: 'admin',
canManage: true,
canEditContent: false
}, '2062179707935264769'), null);
assert.equal(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: 'true',
canEditContent: false
}, '2062179707935264769').canManage, false);
assert.equal(AdminPermissionPages.normalizePermissionOverview({
genealogyId: '2062179707935264769',
roleType: 'legacy-role',
canManage: true,
canEditContent: false
}, '2062179707935264769'), null);
});
test('权限概览依据当前能力展示可管理、审核与内容维护状态', () => {
const admin = {
genealogyId: '2062179707935264769',
roleType: 'admin',
canManage: true,
canEditContent: false
};
const member = {
genealogyId: '2062179707935264769',
roleType: 'member',
canManage: false,
canEditContent: false
};
assert.match(AdminPermissionPages.renderCurrentPermissions(admin), /当前角色[\s\S]*管理员/);
assert.match(AdminPermissionPages.renderCurrentPermissions(admin), /成员角色维护[\s\S]*可管理/);
assert.match(AdminPermissionPages.renderCurrentPermissions(admin), /内容维护[\s\S]*可维护/);
assert.match(AdminPermissionPages.renderCurrentPermissions(member), /入谱审核[\s\S]*不可审核/);
assert.equal(AdminPermissionPages.canMaintainContent({ canManage: 'true', canEditContent: false }), false);
});
test('管理员权限页使用既有家谱详情接口和统一状态,不提供 App 粒度授权表单', () => {
const page = read('profile-admin-permissions.html');
const script = read('public/js/admin-permission-pages.js');
assert.match(page, /data-admin-permission-current[\s\S]*data-api-state="loading"/);
assert.match(page, /src="public\/js\/admin-permission-pages\.js"/);
assert.match(script, /api\.genealogyDetail\(genealogyId\)/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /当前账号无权查看该家谱权限。/);
assert.doesNotMatch(page, /<input|<select|<form/);
assert.doesNotMatch(script, /addManageUser|getManagePower|auth_str/);
});
+40
View File
@@ -184,6 +184,9 @@ test('相册页面拆分列表、编辑和照片详情且不允许手填 OSS ID
});
assert.match(listPage, /data-album-list/);
assert.match(editPage, /name="albumName"/);
assert.match(editPage, /data-album-form[^>]+novalidate/);
assert.match(editPage, /class="field-required"/);
assert.match(editPage, /<div class="api-status" data-album-form-status/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-album-detail/);
@@ -191,5 +194,42 @@ test('相册页面拆分列表、编辑和照片详情且不允许手填 OSS ID
assert.match(detailPage, /name="ossId" type="hidden"/);
assert.match(detailPage, /name="shootTime" type="datetime-local"/);
assert.match(detailPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-album-photo-form novalidate/);
assert.match(detailPage, /<div class="api-status" data-album-photo-status/);
assert.doesNotMatch(editPage + detailPage, /type="text"[^>]+(?:coverOssId|ossId)|name="albumId"|name="photoId"/);
});
test('相册列表、详情和表单将加载、空数据、403 与请求失败写入统一状态组件', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-album.html'), 'utf8');
const detailPage = fs.readFileSync(path.join(projectRoot, 'profile-album-detail.html'), 'utf8');
const source = fs.readFileSync(modulePath, 'utf8');
assert.match(listPage, /data-album-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-album-detail[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-album-photo-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setStatus\('\[data-album-list\]', '正在加载相册…', 'loading'\)/);
assert.match(source, /setStatus\('\[data-album-list\]', '暂无相册', 'empty'\)/);
assert.match(source, /setStatus\('\[data-album-photo-list\]', '正在加载照片…', 'loading'\)/);
assert.match(source, /setStatus\('\[data-album-photo-list\]', '暂无照片', 'empty'\)/);
assert.match(source, /当前账号无权查看该相册照片。/);
assert.match(source, /正在读取家谱权限…', 'loading'/);
assert.match(source, /setStatus\('\[data-album-form-status\]', validation, 'error'\)/);
assert.match(source, /setStatus\('\[data-album-photo-status\]', validation, 'error'\)/);
assert.match(source, /getApiStateType\(error\)/);
});
test('相册与照片只接受后端布尔能力字段,并在所有写路径上门控', () => {
const source = fs.readFileSync(modulePath, 'utf8');
const canEditAlbums = requireFunction('canEditAlbums');
assert.equal(canEditAlbums({ canEditContent: true }), true);
assert.equal(canEditAlbums({ canManage: true }), true);
assert.equal(canEditAlbums({ canEditContent: 'true', canManage: false }), false);
assert.match(source, /canEditContent = canEditAlbums\(detail\)/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !albumId \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !normalizeId\(albumId\) \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !albumId \|\| !normalizeId\(photoId\) \|\| redirectUnauthorized\(api\)\) return;/);
});
+2 -2
View File
@@ -737,7 +737,7 @@ test('feedback methods use the PC collection path and AppFeedbackBody whitelist'
await client.submitFeedback({
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
contactInfo: '测试联系方式',
feedbackTitle: 'must-drop',
appUserId: 'must-drop'
});
@@ -747,7 +747,7 @@ test('feedback methods use the PC collection path and AppFeedbackBody whitelist'
['post', '/genealogy/pc/feedback', {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
contactInfo: '测试联系方式'
}],
['get', '/genealogy/pc/feedback', undefined]
]);
+12 -6
View File
@@ -119,17 +119,23 @@ test('推广区域只把 401 视为登录失效', () => {
assert.equal(AppPromotionPages.isUnauthorized(new Error('网络失败')), false);
});
test('推广区域提供明确的未登录和空列表状态', () => {
assert.match(
AppPromotionPages.renderPromotionLoginRequired(),
/<a href="login\.html">立即登录<\/a>/
);
assert.match(AppPromotionPages.renderPromotionList([]), /当前暂无应用推广/);
test('推广区域将空、无权限与请求失败交给统一状态组件呈现', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../public/js/app-promotion-pages.js'), 'utf8');
assert.equal(AppPromotionPages.renderPromotionList([]), '');
assert.equal(AppPromotionPages.isForbidden({ status: 403 }), true);
assert.equal(AppPromotionPages.isForbidden({ status: 401 }), false);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setPromotionState\(list, 'loading', '正在读取应用推广…'\)/);
assert.match(source, /setPromotionState\(list, 'empty', '当前暂无应用推广。'\)/);
assert.match(source, /isForbidden\(error\) \? 'forbidden' : 'error'/);
});
test('应用下载页只通过页面标记和新推广模块接入,不暴露业务 ID 输入', () => {
assert.match(appPage, /<body[^>]*data-promotion-page/);
assert.match(appPage, /data-promotion-list/);
assert.match(appPage, /data-api-state="loading"/);
assert.match(appPage, /src="public\/js\/profile-common\.js"/);
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)"/);
+24 -1
View File
@@ -166,7 +166,7 @@ test('谱文写后重读必须返回同一条稳定记录', () => {
assert.equal(matchesSavedArticle({}, '2060000000000000001'), false);
});
test('谱文页面开放 PC CRUD 且不提供手填分类、业务 ID 或停用状态', () => {
test('谱文页面按家谱能力开放 PC CRUD且不提供手填分类、业务 ID 或停用状态', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-article.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-article-edit.html'), 'utf8');
@@ -178,11 +178,34 @@ test('谱文页面开放 PC CRUD 且不提供手填分类、业务 ID 或停用
assert.match(listPage, /public\/js\/article-pages\.js/);
assert.doesNotMatch(editPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(editPage, /name="articleTitle"/);
assert.match(editPage, /data-article-form[^>]*novalidate/);
assert.match(editPage, /for="articleTitle">谱文标题<span class="field-required"/);
assert.match(editPage, /name="articleSummary"/);
assert.match(editPage, /name="articleContent"/);
assert.match(editPage, /for="articleContent">正文<span class="field-required"/);
assert.match(editPage, /name="authorName"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /data-article-form-status role="status" aria-live="polite"/);
assert.match(editPage, /public\/js\/article-pages\.js/);
assert.doesNotMatch(editPage, /name="articleId"|name="categoryId"|type="text"[^>]+coverOssId/);
});
test('谱文只接受后端布尔能力字段,并在提交层保持写权限门控', () => {
const script = fs.readFileSync(modulePath, 'utf8');
assert.equal(requireFunction('canEditArticles')({ canEditContent: true }), true);
assert.equal(requireFunction('canEditArticles')({ canManage: true }), true);
assert.equal(requireFunction('canEditArticles')({ canEditContent: 'true', canManage: false }), false);
assert.match(script, /if \(writePending \|\| !canEditContent \|\| !genealogyId/);
assert.match(script, /canEditContent = canEditArticles\(detail\)/);
});
test('谱文编辑表单使用统一状态与提交中反馈', () => {
const script = fs.readFileSync(modulePath, 'utf8');
assert.match(script, /setFormStatus\('正在读取家谱权限…', 'loading'\)/);
assert.match(script, /setFormStatus\(validation, 'error'\)/);
assert.match(script, /setWritePending\(true, '正在保存…'\)/);
assert.match(script, /setFormStatus\('正在保存谱文…', 'loading'\)/);
});
+20
View File
@@ -100,6 +100,26 @@ test('registration page exposes the documented SMS and confirmation inputs', ()
assert.match(source, /name="confirmPassword"/);
});
test('认证表单提供可见标签、统一状态容器和提交中反馈,且不暗示邀请码流程', () => {
const login = fs.readFileSync(path.join(__dirname, '..', 'login.html'), 'utf8');
const register = fs.readFileSync(path.join(__dirname, '..', 'register.html'), 'utf8');
const reset = fs.readFileSync(path.join(__dirname, '..', 'forgot-password.html'), 'utf8');
const script = fs.readFileSync(path.join(__dirname, '..', 'public/js/auth-pages.js'), 'utf8');
[login, register, reset].forEach((source) => {
assert.match(source, /class="auth-field"/);
assert.match(source, /role="status" aria-live="polite" data-auth-form-status/);
assert.match(source, /public\/js\/profile-common\.js/);
assert.match(source, /novalidate/);
});
assert.doesNotMatch(register, /邀请码/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /setSubmitPending\(/);
assert.match(script, /正在登录…/);
assert.match(script, /正在注册…/);
assert.match(script, /正在重设…/);
});
test('password login page retains a verification token field for the PC captcha strategy', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'login.html'), 'utf8');
+248
View File
@@ -0,0 +1,248 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const projectRoot = path.join(__dirname, '..');
function loadProfileUI() {
const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8');
const window = {
jQuery(value) {
if (typeof value === 'function') return undefined;
return {};
}
};
window.window = window;
vm.runInNewContext(source, { window, URLSearchParams });
return window.ProfileUI;
}
function loadProfileStateWithoutJquery() {
const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8');
const window = {};
window.window = window;
vm.runInNewContext(source, { window, URLSearchParams });
return window.ProfileUI;
}
function loadProfileUIWithLinks(links, search) {
const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8');
const window = { location: { search } };
function wrapLink(link) {
return {
is(selector) {
return selector === '[data-genealogy-context-link]' && link.contextLink === true;
},
attr(name, value) {
if (value === undefined) return link[name];
link[name] = value;
return this;
}
};
}
function wrapLinks(items) {
return {
filter(callback) {
return wrapLinks(items.filter((link) => callback.call(link)));
},
each(callback) {
items.forEach((link) => callback.call(link));
return this;
}
};
}
window.jQuery = function (value) {
if (typeof value === 'function') return undefined;
if (typeof value === 'string') {
assert.equal(value, '[data-genealogy-context-link], a[href]');
return wrapLinks(links);
}
return wrapLink(value);
};
window.window = window;
vm.runInNewContext(source, { window, URLSearchParams });
return window.ProfileUI;
}
test('公共业务状态统一输出加载、空数据、失败和无权限语义', () => {
const ui = loadProfileUI();
assert.match(ui.renderApiState('loading', '正在加载'), /data-api-state="loading"[^>]+role="status"[^>]+aria-busy="true"/);
assert.match(ui.renderApiState('empty', '暂无内容'), /data-api-state="empty"[^>]+role="status"/);
assert.match(ui.renderApiState('error', '读取失败'), /data-api-state="error"[^>]+role="alert"/);
assert.match(ui.renderApiState('forbidden', '没有权限'), /data-api-state="forbidden"[^>]+role="alert"/);
assert.throws(() => ui.renderApiState('unknown', '未知状态'), /不支持的业务状态/);
});
test('公共业务状态转义接口消息并由单一方法写入容器', () => {
const ui = loadProfileUI();
const container = { innerHTML: '' };
ui.setApiState(container, 'error', '<img src=x onerror=alert(1)>');
assert.doesNotMatch(container.innerHTML, /<img/);
assert.match(container.innerHTML, /&lt;img/);
});
test('独立表单页不加载 jQuery 时仍可复用公共业务状态 owner', () => {
const ui = loadProfileStateWithoutJquery();
assert.equal(typeof ui.renderApiState, 'function');
assert.equal(typeof ui.setApiState, 'function');
});
test('业务模块在初始化前先加载统一的家谱上下文与状态 owner', () => {
const pages = {
'profile.html': ['profile-pages.js'],
'profile-data.html': ['upload-pages.js', 'region-pages.js', 'profile-pages.js'],
'profile-families.html': ['genealogy-entry-pages.js'],
'profile-feedback.html': ['feedback-pages.js'],
'profile-security.html': ['captcha-pages.js', 'security-pages.js'],
'profile-services.html': ['vip-pages.js']
};
Object.entries(pages).forEach(([page, modules]) => {
const source = fs.readFileSync(path.join(projectRoot, page), 'utf8');
const commonIndex = source.indexOf('src="public/js/profile-common.js');
assert.ok(commonIndex >= 0, `${page} 未加载 ProfileUI`);
modules.forEach((module) => {
assert.ok(commonIndex < source.indexOf('src="public/js/' + module + '"'), `${page} 应先加载 ProfileUI 再加载 ${module}`);
});
});
});
test('实际会刷新内容的表单状态容器统一具备可访问性语义', () => {
const pages = {
'profile-data.html': ['profileAvatarStatus'],
'profile-album-edit.html': ['albumCoverStatus', 'data-album-form-status'],
'profile-album-detail.html': ['albumPhotoUploadStatus', 'data-album-photo-status'],
'profile-article-edit.html': ['articleCoverStatus'],
'profile-ceremony-detail.html': ['data-ceremony-gift-status', 'data-ceremony-invitee-status'],
'profile-families.html': ['data-genealogy-entry-status'],
'profile-family-home.html': ['data-family-home-status'],
'profile-feed-edit.html': ['feedMediaStatus', 'data-feed-form-status'],
'profile-gift-edit.html': ['giftCoverStatus', 'data-ceremony-form-status'],
'profile-gift.html': ['data-my-invitation-status'],
'profile-growth-edit.html': ['growthMediaStatus'],
'profile-memo-edit.html': ['memoMediaStatus'],
'profile-relative-edit.html': ['relativeMediaStatus'],
'profile-tree.html': ['data-lineage-page-status', 'data-lineage-binding-status', 'lineage-avatar-status'],
'profile-video-edit.html': ['video-file-status', 'video-cover-status', 'data-video-form-status']
};
Object.entries(pages).forEach(([page, markers]) => {
const source = fs.readFileSync(path.join(projectRoot, page), 'utf8');
markers.forEach((marker) => {
const attribute = marker.indexOf('data-') === 0 ? marker : 'id="' + marker + '"';
const tag = source.match(new RegExp('<[^>]*' + attribute + '[^>]*>'));
assert.ok(tag, `${page} 缺少 ${marker} 状态容器`);
assert.match(tag[0], /role="status"/, `${page}${marker} 缺少状态角色`);
assert.match(tag[0], /aria-live="polite"/, `${page}${marker} 缺少实时反馈`);
});
});
});
test('家谱业务页链接由公共上下文 owner 统一识别', () => {
const ui = loadProfileUI();
const source = fs.readFileSync(path.join(projectRoot, 'public/js/profile-common.js'), 'utf8');
assert.equal(ui.isGenealogyContextHref('profile-article.html'), true);
assert.equal(ui.isGenealogyContextHref('profile-video-edit.html?videoId=1'), true);
assert.equal(ui.isGenealogyContextHref('profile-family-admin.html#members'), true);
assert.equal(ui.isGenealogyContextHref('profile-data.html'), false);
assert.equal(ui.isGenealogyContextHref('https://example.com/profile-article.html'), false);
assert.match(source, /\[data-genealogy-context-link\], a\[href\]/);
});
test('家谱业务页跳转自动透传上下文,未选家谱时回到家谱入口', () => {
const links = [
{ href: 'profile-article.html?articleId=1' },
{ href: 'profile-data.html' },
{ href: 'profile-data.html', contextLink: true }
];
const ui = loadProfileUIWithLinks(links, '?genealogyId=99');
ui.syncGenealogyContextLinks();
assert.equal(links[0].href, 'profile-article.html?articleId=1&genealogyId=99');
assert.equal(links[1].href, 'profile-data.html');
assert.equal(links[2].href, 'profile-data.html?genealogyId=99');
const noContextLinks = [{ href: 'profile-video.html?videoId=1' }];
const noContextUi = loadProfileUIWithLinks(noContextLinks, '');
noContextUi.syncGenealogyContextLinks();
assert.equal(noContextLinks[0].href, 'profile-families.html?next=' + encodeURIComponent('profile-video.html?videoId=1'));
assert.equal(
noContextUi.getGenealogyEntryUrl('profile-video-edit.html?videoId=1#cover'),
'profile-families.html?next=' + encodeURIComponent('profile-video-edit.html?videoId=1#cover')
);
});
test('谱文列表、详情和编辑页接入公共业务状态 owner', () => {
const script = fs.readFileSync(path.join(projectRoot, 'public/js/article-pages.js'), 'utf8');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-article.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-article-edit.html'), 'utf8');
const css = fs.readFileSync(path.join(projectRoot, 'public/css/public.css'), 'utf8');
assert.match(script, /root\.ProfileUI\.renderApiState/);
assert.match(script, /root\.ProfileUI\.setApiState/);
assert.match(script, /'loading'[\s\S]+?'empty'[\s\S]+?'forbidden'[\s\S]+?'error'/);
assert.match(listPage, /data-api-state="loading"/);
assert.match(listPage, /data-api-state="empty"/);
assert.match(editPage, /data-api-state="loading"/);
assert.match(css, /\.api-state--error,[\s\S]+\.api-state--forbidden/);
});
test('内容工作台的初始加载与待选详情占位使用统一状态标记', () => {
const listPages = [
'profile-feed.html',
'profile-generation.html',
'profile-growth.html',
'profile-relative.html',
'profile-merit.html',
'profile-memo.html'
];
const detailPages = [
'profile-growth.html',
'profile-relative.html',
'profile-merit.html',
'profile-memo.html'
];
listPages.forEach((page) => {
const source = fs.readFileSync(path.join(projectRoot, page), 'utf8');
assert.match(source, /api-state--loading[^>]*data-api-state="loading"/);
assert.doesNotMatch(source, /api-empty/);
});
detailPages.forEach((page) => {
const source = fs.readFileSync(path.join(projectRoot, page), 'utf8');
assert.match(source, /api-state--empty[^>]*data-api-state="empty"/);
});
});
test('内容记录详情在未选中记录时也使用统一空状态组件', () => {
const detailRenderers = [
[require('../public/js/growth-pages.js').renderGrowthDetail, '成长记录'],
[require('../public/js/relative-pages.js').renderRelativeDetail, '亲友记录'],
[require('../public/js/memo-pages.js').renderMemoDetail, '备忘录'],
[require('../public/js/merit-pages.js').renderMeritDetail, '功德记录']
];
detailRenderers.forEach(([renderDetail, label]) => {
const html = renderDetail(null);
assert.match(html, /api-state--empty/, `${label} 未使用统一空状态`);
assert.doesNotMatch(html, /api-empty/, `${label} 仍保留旧空状态`);
});
});
+51 -3
View File
@@ -105,7 +105,7 @@ test('活动、祭品和邀请响应使用完整 PC VO 并拒绝不安全 ID', (
genealogyId: '2060000000000000002',
sponsorUserId: '2060000000000000003',
sponsorNickName: '叶子',
sponsorPhone: '19181970173',
sponsorPhone: '测试联系方式',
ceremonyType: 'ancestor',
ceremonyTitle: '清明祭祖',
ceremonyDesc: '缅怀先祖',
@@ -205,7 +205,7 @@ test('活动和祭品展示转义内容且不暴露手机号、OSS ID 或内部
genealogyId: '2060000000000000002',
sponsorUserId: '2060000000000000003',
sponsorNickName: '<img src=x>叶子',
sponsorPhone: '19181970173',
sponsorPhone: '测试联系方式',
ceremonyType: 'ancestor',
ceremonyTitle: '<script>alert(1)</script>祭祖',
coverOssId: '2060000000000000004',
@@ -226,7 +226,7 @@ test('活动和祭品展示转义内容且不暴露手机号、OSS ID 或内部
})]);
assert.doesNotMatch(html, /<script|<img/);
assert.doesNotMatch(html, /19181970173|19100000000|206000000000000000[1-6]/);
assert.doesNotMatch(html, /测试联系方式|19100000000|206000000000000000[1-6]/);
assert.match(html, /祭祖|缅怀|66\.66/);
});
@@ -242,10 +242,58 @@ test('祭祀功能拆分列表、编辑和详情页面且不允许手填 ID', ()
});
assert.match(listPage, /data-ceremony-list/);
assert.match(editPage, /name="ceremonyType"/);
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.doesNotMatch(editPage, /创建贺礼|贺礼标题|贺礼内容/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(detailPage, /data-ceremony-gift-list/);
assert.match(detailPage, /data-ceremony-invitee-options/);
assert.match(detailPage, /name="giftAmount"/);
assert.match(detailPage, /data-ceremony-gift-form data-ceremony-admin-editor hidden novalidate/);
assert.match(detailPage, /data-ceremony-invitee-form data-ceremony-admin-editor hidden novalidate/);
assert.match(detailPage, /<div class="upload-status" data-ceremony-gift-status/);
assert.match(detailPage, /<div class="upload-status" data-ceremony-invitee-status/);
assert.doesNotMatch(editPage + detailPage, /name="(?:genealogyId|ceremonyId|giftId|inviteeUserIds)"/);
});
test('活动、祭品和受邀名单复用统一加载、空数据、403 与失败状态', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-ceremony.html'), 'utf8');
const detailPage = fs.readFileSync(path.join(projectRoot, 'profile-ceremony-detail.html'), 'utf8');
const source = fs.readFileSync(modulePath, 'utf8');
assert.match(listPage, /data-ceremony-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-ceremony-detail[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-ceremony-gift-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-ceremony-invitation-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-ceremony-invitee-options[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setStatus\('\[data-ceremony-list\]', '正在加载祭祀活动…', 'loading'\)/);
assert.match(source, /setStatus\('\[data-ceremony-list\]', '暂无祭祀活动', 'empty'\)/);
assert.match(source, /setStatus\('\[data-ceremony-gift-list\]', '暂无祭品记录', 'empty'\)/);
assert.match(source, /setStatus\('\[data-ceremony-invitation-list\]', '暂无邀请记录', 'empty'\)/);
assert.match(source, /当前账号无权查看或维护受邀名单。/);
assert.match(source, /正在读取家谱权限…', 'loading'/);
assert.match(source, /setStatus\('\[data-ceremony-form-status\]', validation, 'error'\)/);
assert.match(source, /setStatus\('\[data-ceremony-gift-status\]', validation, 'error'\)/);
assert.match(source, /正在更新受邀人…', 'loading'/);
assert.match(source, /getApiStateType\(error\)/);
});
test('祭祀管理只接受后端布尔能力字段,并在全部写路径上门控', () => {
const source = fs.readFileSync(modulePath, 'utf8');
const canEditCeremonies = requireFunction('canEditCeremonies');
assert.equal(canEditCeremonies({ canEditContent: true }), true);
assert.equal(canEditCeremonies({ canManage: true }), true);
assert.equal(canEditCeremonies({ canEditContent: 'true', canManage: false }), false);
assert.match(source, /canEditContent = canEditCeremonies\(detail\)/);
assert.match(source, /root\.AppRichEditor && root\.AppRichEditor\.syncAll/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !ceremonyId \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !normalizeId\(ceremonyId\) \|\| redirectUnauthorized\(api\)\) return;/);
assert.match(source, /if \(writePending \|\| !canEditContent \|\| !genealogyId \|\| !ceremonyId \|\| !normalizeId\(giftId\) \|\| redirectUnauthorized\(api\)\) return;/);
});
+18
View File
@@ -136,3 +136,21 @@ test('贺礼邀请页面只接入我的邀请闭环,不提供活动或献礼
assert.doesNotMatch(page, /data-ceremony-gift-form/);
assert.doesNotMatch(page, /href="profile-gift-edit\.html"/);
});
test('我的邀请将列表、详情和响应结果写入统一状态组件', () => {
const root = path.join(__dirname, '..');
const page = fs.readFileSync(path.join(root, 'profile-gift.html'), 'utf8');
const source = fs.readFileSync(path.join(root, 'public', 'js', 'ceremony-pages.js'), 'utf8');
assert.match(page, /data-my-invitation-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(page, /data-my-invitation-detail[^>]*>[\s\S]*data-api-state="empty"/);
assert.match(page, /data-my-invitation-status/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setInvitationState\('\[data-my-invitation-list\]', 'loading', '正在加载我的邀请…'\)/);
assert.match(source, /setInvitationState\('\[data-my-invitation-list\]', 'empty', '暂无贺礼邀请'\)/);
assert.match(source, /当前账号无权查看邀请。/);
assert.match(source, /正在更新邀请状态…/);
assert.match(source, /当前账号无权响应该邀请。/);
assert.equal(CeremonyPages.isForbidden({ status: 403 }), true);
assert.equal(CeremonyPages.isForbidden({ status: 401 }), false);
});
+33 -2
View File
@@ -119,6 +119,8 @@ test('家谱主页只把缺失登录态和 401 视为登录失效', () => {
assert.equal(FamilyHomePages.shouldRedirectToLogin({
getToken() { return ''; }
}), true);
assert.equal(FamilyHomePages.isForbidden({ status: 403 }), true);
assert.equal(FamilyHomePages.isForbidden({ status: 401 }), false);
});
test('家谱主页只读取当前家谱概览和世系树', async () => {
@@ -152,30 +154,57 @@ test('家谱主页只读取当前家谱概览和世系树', async () => {
assert.deepEqual(result.tree, tree);
});
test('家谱主页开放真实只读概览且不制造邀请或聚合统计入口', () => {
test('家谱主页以 PC 工作台分组开放已接入功能且不制造旧 APP 伪功能', () => {
const source = fs.readFileSync(path.join(root, 'profile-family-home.html'), 'utf8');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'family-home-pages.js'), 'utf8');
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, /亲友往来(贺礼簿)/);
assert.match(source, /备忘录(家族恩人)/);
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, /data-family-home-overview[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(source, /data-lineage-home-tree[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(source, /src="public\/js\/lineage-pages\.js"/);
assert.match(source, /src="public\/js\/family-home-pages\.js"/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /setHomeState\('\[data-family-home-overview\]', 'loading', '正在加载家谱概览…'\)/);
assert.match(script, /setHomeState\('\[data-lineage-home-tree\]', 'loading', '正在加载世系树…'\)/);
assert.match(script, /setHomeState\('\[data-lineage-home-tree\]', 'empty', '暂无世系树'\)/);
assert.match(script, /treeHtml = Array\.isArray\(result\) \? root\.LineagePages\.renderLineageTreeHtml\(result\) : '';/);
assert.match(script, /if \(!treeHtml\) \{/);
assert.match(script, /当前账号无权查看该家谱世系树。/);
[
'profile-feed.html',
'profile-tree.html',
'profile-generation.html',
'profile-article.html',
'profile-album.html',
'profile-video.html',
'profile-merit.html',
'profile-ceremony.html'
'profile-ceremony.html',
'profile-gift.html',
'profile-growth.html',
'profile-relative.html',
'profile-memo.html',
'profile-family-admin.html',
'profile-services.html'
].forEach((href) => {
assert.match(
source,
@@ -183,4 +212,6 @@ test('家谱主页开放真实只读概览且不制造邀请或聚合统计入
`${href} 缺少家谱上下文`
);
});
assert.match(source, /<h3>会员与订单<\/h3>/);
assert.match(source, /关联当前家谱创建会员订单,并查看已有订单。/);
});
+53 -1
View File
@@ -242,6 +242,13 @@ test('家谱能力和当前用户决定编辑、管理与本人删除上下文',
);
});
test('家族圈编辑能力只接受真实布尔权限,并允许管理者维护内容', () => {
assert.equal(FeedPages.canEditFeeds({ canEditContent: true }), true);
assert.equal(FeedPages.canEditFeeds({ canManage: true }), true);
assert.equal(FeedPages.canEditFeeds({ canEditContent: 'true', canManage: false }), false);
assert.equal(FeedPages.canEditFeeds({ canEditContent: false, canManage: 'true' }), false);
});
test('家族圈页面提供真实详情、分页和上传控件,不暴露 OSS ID 或草稿伪语义', () => {
const listPage = read('profile-feed.html');
const editPage = read('profile-feed-edit.html');
@@ -250,10 +257,13 @@ test('家族圈页面提供真实详情、分页和上传控件,不暴露 OSS
assert.match(listPage, /data-feed-page-prev/);
assert.match(listPage, /data-feed-page-next/);
assert.match(listPage, /data-feed-create-link[^>]+data-genealogy-context-link|data-genealogy-context-link[^>]+data-feed-create-link/);
assert.match(listPage, /data-feed-create-link[^>]+data-genealogy-context-link[^>]+hidden|data-feed-create-link[^>]+hidden[^>]+data-genealogy-context-link/);
assert.match(detailPage, /data-feed-detail-page/);
assert.match(detailPage, /data-feed-detail/);
assert.match(detailPage, /data-feed-detail-comments/);
assert.match(detailPage, /data-feed-comment-form novalidate/);
assert.match(detailPage, /name="commentContent"[^>]+required/);
assert.match(detailPage, /data-feed-comment-form-status/);
assert.match(detailPage, /data-feed-list-link[^>]+data-genealogy-context-link|data-genealogy-context-link[^>]+data-feed-list-link/);
assert.doesNotMatch(editPage, /type="text"[^>]+name="mediaOssIds"|name="mediaOssIds"[^>]+type="text"/);
assert.match(editPage, /name="mediaOssIds"[^>]+type="hidden"|type="hidden"[^>]+name="mediaOssIds"/);
@@ -267,3 +277,45 @@ test('家族圈页面提供真实详情、分页和上传控件,不暴露 OSS
assert.match(script, /genealogyDetail/);
assert.match(script, /field\.disabled/);
});
test('评论和回复使用详情页表单与统一状态,不再使用浏览器提示框', () => {
const detailPage = read('profile-feed-detail.html');
const script = read('public/js/feed-pages.js');
assert.match(detailPage, /data-feed-comment-form-target/);
assert.match(detailPage, /data-feed-comment-cancel/);
assert.match(detailPage, /data-feed-detail[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(detailPage, /data-feed-detail-comments[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(script, /function openFeedCommentForm\(feedId, parentCommentId\)/);
assert.match(script, /function submitComment\(form\)/);
assert.match(script, /function renderFeedState\(type, message\)/);
assert.match(script, /function setReplyState\(parentCommentId, type, message\)/);
assert.match(script, /setFeedCommentFormStatus\('正在发布评论…', 'loading'\)/);
assert.match(script, /setFeedCommentFormEnabled\(false\)/);
assert.match(script, /setFeedContainerState\('\[data-feed-detail-comments\]', 'loading', '正在加载评论…'\)/);
assert.match(script, /setReplyState\(commentId, 'loading', '正在加载回复…'\)/);
assert.match(script, /当前账号无权查看回复。/);
assert.doesNotMatch(script, /api-empty/);
assert.doesNotMatch(script, /root\.prompt/);
});
test('动态发布表单明确必填项,并以真实家谱能力控制加载、无权限和保存失败反馈', () => {
const editPage = read('profile-feed-edit.html');
const script = read('public/js/feed-pages.js');
assert.match(editPage, /data-feed-form novalidate/);
assert.match(editPage, /class="field-required"/);
assert.match(editPage, /data-feed-form-status/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /setFeedContainerState\('\[data-feed-list\]', 'loading'/);
assert.match(script, /setFeedContainerState\('\[data-feed-detail\]', 'loading'/);
assert.match(script, /'暂无家族动态'/);
assert.match(script, /!viewerContext\.canEditContent/);
assert.match(script, /setFeedFormEnabled\(false\)/);
assert.match(script, /setFeedSubmitPending\(true\)/);
assert.match(script, /正在保存…/);
assert.match(script, /function canEditFeeds\(genealogy\)/);
assert.match(script, /setFeedContentEditingEnabled\(viewerContext\.canEditContent\)/);
assert.match(script, /if \(!viewerContext\.canEditContent\) \{\s*setFeedFormStatus\('当前账号无权发布或编辑该家谱的动态。', 'forbidden'\)/);
assert.match(editPage, /data-feed-editor-action hidden disabled/);
});
+29 -4
View File
@@ -19,7 +19,7 @@ function feedbackFixture(overrides) {
appUserPhone: '19100000000',
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
contactInfo: '测试联系方式',
handleStatus: '2',
handleResult: '已经修复',
handlerId: '2062179707935264702',
@@ -33,14 +33,14 @@ test('反馈请求只构造 AppFeedbackBody 并裁剪文本', () => {
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: ' bug ',
feedbackContent: ' 页面按钮无响应 ',
contactInfo: ' 19181970173 ',
contactInfo: ' 测试联系方式 ',
feedbackTitle: 'must-drop',
appUserId: 'must-drop',
handlerId: 'must-drop'
}), {
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173'
contactInfo: '测试联系方式'
});
assert.deepEqual(FeedbackPages.buildFeedbackBody({
feedbackType: '',
@@ -69,7 +69,7 @@ test('FeedbackVo 只保留用户可见字段并保持长 ID 字符串', () => {
feedbackId,
feedbackType: 'bug',
feedbackContent: '页面按钮无响应',
contactInfo: '19181970173',
contactInfo: '测试联系方式',
handleStatus: '2',
handleResult: '已经修复',
handleTime: '2026-07-29 13:00:00',
@@ -137,10 +137,12 @@ test('两个提交页只提供 AppFeedbackBody 字段并加载真实脚本', ()
assert.doesNotMatch(source, /data-feature-status="pending"|pending-pages\.js/);
assert.match(source, /data-feedback-form/);
assert.match(source, /data-feedback-form novalidate/);
assert.match(source, /name="feedbackType"/);
assert.match(source, /name="feedbackContent"/);
assert.match(source, /name="contactInfo"/);
assert.match(source, /public\/js\/feedback-pages\.js/);
assert.match(source, /role="status"[^>]*data-feedback-status/);
assert.doesNotMatch(source, /name="(?:feedbackTitle|feedbackId|appUserId|handlerId)"/);
});
});
@@ -154,8 +156,10 @@ test('我的工单和详情页读取同一反馈集合且不制造处理动作',
assert.match(source, /public\/js\/feedback-pages\.js/);
});
assert.match(listPage, /data-feedback-list/);
assert.match(listPage, /data-api-state="loading"/);
assert.match(listPage, /data-feedback-refresh/);
assert.match(detailPage, /data-feedback-detail/);
assert.match(detailPage, /data-api-state="loading"/);
assert.doesNotMatch(detailPage, /回复|追问|删除|关闭工单|name="feedbackId"/);
});
@@ -167,3 +171,24 @@ test('反馈运行时提交后重读同一 ID,详情不存在时不回退', ()
assert.match(source, /提交后无法读取同一反馈记录/);
assert.match(source, /反馈记录不存在或无权查看/);
});
test('反馈请求的 403 显式归类为无权限,而不是登录失效', () => {
assert.equal(FeedbackPages.isForbidden({ status: 403 }), true);
assert.equal(FeedbackPages.isForbidden({ code: '403' }), true);
assert.equal(FeedbackPages.isForbidden({ status: 401 }), false);
});
test('反馈表单与列表使用可见标签和统一加载、空数据、失败状态', () => {
const ticket = read('submit-ticket.html');
const script = read('public/js/feedback-pages.js');
assert.match(ticket, /<label for="ticketContactInfo">联系方式(可选)<\/label>/);
assert.match(ticket, /<label for="ticketFeedbackType">反馈类型<\/label>/);
assert.match(ticket, /<label for="ticketFeedbackContent">问题描述/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /setApiState\(list, 'loading', '正在读取反馈记录…'\)/);
assert.match(script, /setApiState\(list, 'empty', '当前没有反馈记录。'\)/);
assert.match(script, /setApiState\(detail, isForbidden\(error\) \? 'forbidden' : 'error'/);
assert.match(script, /setPendingButton\(button, true, '正在提交…'\)/);
assert.match(script, /isForbidden\(error\) \? 'forbidden' : 'error'/);
});
+37
View File
@@ -0,0 +1,37 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const projectRoot = path.join(__dirname, '..');
test('all visible form controls have a label or accessible name', () => {
const pages = fs.readdirSync(projectRoot).filter((name) => name.endsWith('.html'));
pages.forEach((page) => {
const source = fs.readFileSync(path.join(projectRoot, page), 'utf8');
Array.from(source.matchAll(/<form\b[\s\S]*?<\/form>/gi)).forEach((form) => {
const formHtml = form[0];
Array.from(formHtml.matchAll(/<(input|select|textarea)\b[^>]*>/gi)).forEach((control) => {
const tag = control[0];
const type = (tag.match(/\btype=["']([^"']+)/i) || [, ''])[1].toLowerCase();
const id = (tag.match(/\bid=["']([^"']+)/i) || [, ''])[1];
const name = (tag.match(/\bname=["']([^"']+)/i) || [, ''])[1] || 'unnamed control';
const before = formHtml.slice(0, control.index);
const hasExplicitLabel = Boolean(id) && (
formHtml.includes('for="' + id + '"') || formHtml.includes("for='" + id + "'")
);
const hasImplicitLabel = before.lastIndexOf('<label') > before.lastIndexOf('</label');
const hasAccessibleName = /\baria-label(?:ledby)?=["'][^"']+/i.test(tag);
if (['hidden', 'submit', 'button', 'reset'].includes(type) || /\bhidden\b/i.test(tag)) return;
assert.ok(
hasExplicitLabel || hasImplicitLabel || hasAccessibleName,
page + ' control ' + name + ' has no label or accessible name'
);
});
});
});
});
+82 -2
View File
@@ -11,6 +11,10 @@ function read(relativePath) {
test('家谱入口只接受本地家谱业务页作为返回目标', () => {
assert.equal(GenealogyEntryPages.getTargetPage('?next=profile-feed.html'), 'profile-feed.html');
assert.equal(
GenealogyEntryPages.getTargetPage('?next=' + encodeURIComponent('profile-feed-detail.html?feedId=12#comments')),
'profile-feed-detail.html?feedId=12#comments'
);
assert.equal(GenealogyEntryPages.getTargetPage('?next=https%3A%2F%2Fevil.example'), 'profile-family-home.html');
});
@@ -39,6 +43,10 @@ test('家谱入口只使用 AppGenealogyVo 的真实字段并保持大整数 ID
GenealogyEntryPages.buildEntryUrl('2062179707935264769', 'profile-feed.html'),
'profile-feed.html?genealogyId=2062179707935264769'
);
assert.equal(
GenealogyEntryPages.buildEntryUrl('2062179707935264769', 'profile-feed-detail.html?feedId=12&genealogyId=old#comments'),
'profile-feed-detail.html?feedId=12&genealogyId=2062179707935264769#comments'
);
});
test('家谱创建只构造 AppGenealogyCreateBody 并省略空可选字段', () => {
@@ -165,11 +173,30 @@ test('负数额度不向用户展示为可用数量', () => {
test('我的家谱页是可用的真实列表入口', () => {
const source = read('profile-families.html');
const script = read('public/js/genealogy-entry-pages.js');
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.doesNotMatch(source, /pending-pages\.js/);
assert.match(source, /data-genealogy-list="mine"/);
assert.match(source, /data-genealogy-list="mine"[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(source, /genealogy-entry-pages\.js/);
assert.match(script, /function setGenealogyListState\(container, type, message\)/);
assert.match(script, /setGenealogyListState\(container, 'loading', '正在读取我的家谱…'\)/);
assert.match(script, /setGenealogyListState\(container, 'empty', '当前账号还没有可进入的家谱'\)/);
assert.match(script, /当前账号无权查看我的家谱。/);
assert.match(script, /result = await api\.genealogiesMine\(\);/);
assert.match(script, /result = await api\.genealogyQuota\(\);/);
});
test('家谱排序在未声明 PC 读取和保存契约时保持明确不可操作状态', () => {
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, /家谱邀请、家谱删除、家谱排序、分享奖励/);
});
test('创建家谱页开放真实 PC 表单且不允许手填业务 ID', () => {
@@ -178,8 +205,17 @@ test('创建家谱页开放真实 PC 表单且不允许手填业务 ID', () => {
assert.doesNotMatch(source, /data-feature-status="pending"/);
assert.doesNotMatch(source, /pending-pages\.js/);
assert.match(source, /data-genealogy-create-page/);
assert.match(source, /data-genealogy-create-quota/);
assert.match(source, /data-genealogy-create-quota role="status" aria-live="polite"/);
assert.match(source, /data-genealogy-create-status/);
assert.match(source, /data-genealogy-create-form novalidate/);
assert.match(source, /class="field-required"/);
assert.match(source, /role="group" aria-labelledby="family-region-label" aria-describedby="family-create-status"/);
assert.match(source, /<label for="familyProvinceCode">省/);
assert.match(source, /<label for="familyCityCode">市/);
assert.match(source, /<label for="familyDistrictCode">区县/);
assert.match(source, /data-region-picker-status role="status" aria-live="polite"/);
assert.match(source, /<div id="family-create-status" class="upload-status" data-genealogy-create-status role="status" aria-live="polite"/);
assert.match(source, /<p id="familyCoverStatus" class="upload-status" role="status" aria-live="polite">/);
assert.match(source, /name="coverOssId"\s+type="hidden"/);
assert.match(source, /data-upload-target="#familyCoverOssId"/);
assert.match(source, /public\/js\/region-pages\.js/);
@@ -190,6 +226,50 @@ test('创建家谱页开放真实 PC 表单且不允许手填业务 ID', () => {
assert.doesNotMatch(source, /name="coverOssId"[^>]*type="text"/);
});
test('公共创建入口复用真实 PC 创建表单,不保留无效的静态提交', () => {
const source = read('create-genealogy.html');
assert.match(source, /data-genealogy-create-page/);
assert.match(source, /data-genealogy-create-form novalidate aria-describedby="public-create-status"/);
assert.match(source, /data-genealogy-create-quota role="status" aria-live="polite"/);
assert.match(source, /data-genealogy-create-status role="status" aria-live="polite"/);
assert.match(source, /<label class="field" for="publicFamilyName">谱名/);
assert.match(source, /<label class="field" for="publicFamilySurname">主要姓氏/);
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, /data-genealogy-create-submit/);
assert.match(source, /public\/js\/profile-common\.js/);
assert.match(source, /public\/js\/region-pages\.js/);
assert.match(source, /public\/js\/genealogy-entry-pages\.js/);
assert.doesNotMatch(source, /data-genealogy-form="create"|data-success-url/);
assert.doesNotMatch(source, /name="(?:genealogyId|ownerUserId|coverOssId)"/);
});
test('创建页保留合法邀请模式,但不把缺失的邀请码管理伪装成已开放能力', () => {
const profileCreate = read('profile-create-family.html');
const publicCreate = read('create-genealogy.html');
[profileCreate, publicCreate].forEach((source) => {
assert.match(source, /<option value="2">邀请码加入<\/option>/);
assert.match(source, /当前 PC 未提供邀请码生成、发送或管理接口/);
});
assert.match(profileCreate, /id="joinMode"[^>]*aria-describedby="family-invite-mode-note"/);
assert.match(publicCreate, /id="publicJoinMode"[^>]*aria-describedby="public-invite-mode-note"/);
});
test('创建家谱把加载、额度不足、校验和请求失败写入统一状态容器', () => {
const source = read('public/js/genealogy-entry-pages.js');
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /正在读取创建额度…', 'loading'/);
assert.match(source, /当前没有可用的家谱创建额度', 'empty'/);
assert.match(source, /setCreateStatus\(validation, 'error'\)/);
assert.match(source, /getApiStateType\(error\)/);
assert.match(source, /正在创建…/);
});
test('加入家谱页面不允许用户手工填写 genealogyId', () => {
const source = read('join-genealogy.html');
@@ -244,7 +324,7 @@ test('当前家谱名称从真实家谱响应写入会话上下文,并在页
assert.match(common, /href="profile-families\.html"/);
assert.match(entry, /data-genealogy-name=/);
assert.match(entry, /root\.ProfileUI\.setCurrentGenealogyContext\(verified\.genealogyId, verified\.genealogyName\)/);
assert.match(familyHome, /root\.ProfileUI\.setCurrentGenealogyContext\(result\.overview\.genealogyId, result\.overview\.genealogyName\)/);
assert.match(familyHome, /root\.ProfileUI\.setCurrentGenealogyContext\(result\.genealogyId, result\.genealogyName\)/);
});
test('小屏导航提供统一入口,并且所有家谱内链接继续透传当前上下文', () => {
+18 -1
View File
@@ -122,6 +122,12 @@ test('家谱内容权限决定读取正常列表还是包含停用项的管理
canEditContent: true,
listMethod: 'generationPoemsManagement'
});
assert.deepEqual(GenerationPages.normalizeGenerationAccess({ canManage: true }), {
canEditContent: true,
listMethod: 'generationPoemsManagement'
});
assert.equal(GenerationPages.canEditGenerations({ canEditContent: 'true', canManage: false }), false);
assert.equal(GenerationPages.canEditGenerations({ canEditContent: false, canManage: 'true' }), false);
});
test('批量预览逐项校验动作、状态、ID 与汇总计数', () => {
@@ -195,15 +201,26 @@ test('停用缺失世代的批量保存确认文案明确不会删除历史', ()
assert.equal(GenerationPages.buildBatchConfirmMessage(false), '确认按当前预览保存字辈吗?');
});
test('字辈批量示例明确使用 Apifox 支持的分隔符', () => {
test('字辈管理提供页内完整表单、统一状态和严格权限,而非浏览器提示框', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-generation.html'), 'utf8');
const styles = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'profile-module.css'), 'utf8');
const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'generation-pages.js'), 'utf8');
assert.match(page, /placeholder="例如:德 承 家 亦(用空格或标点分隔)"/);
assert.doesNotMatch(page, /placeholder="例如:德承家亦"/);
assert.match(page, /data-generation-management[^>]+hidden/);
assert.match(page, /data-generation-add[^>]+disabled|disabled[^>]+data-generation-add/);
assert.match(styles, /\[data-generation-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
assert.match(page, /data-generation-form novalidate/);
assert.match(page, /name="generationNo"[^>]+required/);
assert.match(page, /name="generationText"[^>]+required/);
assert.match(page, /data-generation-form-status/);
assert.match(page, /data-generation-editor-submit/);
assert.match(script, /function openPoemEditor\(poemId\)/);
assert.match(script, /function submitPoem\(form\)/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /if \(writePending \|\| !managementAccess \|\| redirectUnauthorized\(api\) \|\| !form\) return;/);
assert.doesNotMatch(script, /root\.prompt/);
});
test('字辈管理接口的 403 不是登录失效', () => {
+31 -1
View File
@@ -180,7 +180,7 @@ test('世系人物选择器只使用稳定人物编号和名称', () => {
);
});
test('成长记录列表和编辑页开放 PC CRUD 且不允许手填人物或 OSS ID', () => {
test('成长记录列表和编辑页按家谱能力开放 PC CRUD且不允许手填人物或 OSS ID', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-growth.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-growth-edit.html'), 'utf8');
@@ -188,13 +188,43 @@ test('成长记录列表和编辑页开放 PC CRUD 且不允许手填人物或 O
assert.match(listPage, /data-growth-list/);
assert.match(listPage, /data-growth-detail/);
assert.match(listPage, /data-growth-create-link hidden/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="lineagePersonId"[^>]*data-growth-lineage-person/);
assert.match(editPage, /data-growth-form[^>]*novalidate/);
assert.match(editPage, /for="growthTitle">标题<span class="field-required"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.match(editPage, /data-growth-form-status role="status" aria-live="polite"/);
assert.match(editPage, /data-growth-editor-placeholder>[\s\S]*api-state--loading" role="status" aria-live="polite" aria-busy="true"/);
assert.doesNotMatch(editPage, /name="(?:lineagePersonId|mediaOssIds)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/growth-pages\.js/);
assert.match(styles, /\[data-growth-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
test('成长记录只向内容维护者或管理员开放写操作', () => {
const root = path.join(__dirname, '..');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'growth-pages.js'), 'utf8');
assert.equal(GrowthPages.canEditRecords({ canEditContent: true }), true);
assert.equal(GrowthPages.canEditRecords({ canManage: true }), true);
assert.equal(GrowthPages.canEditRecords({ canEditContent: false, canManage: false }), false);
assert.match(script, /await loadCapability\(api, genealogyId\);/);
assert.match(script, /if \(writePending \|\| !canEditContent \|\| !genealogyId/);
assert.match(script, /if \(canEditContent\) \{[\s\S]*data-growth-delete-id/);
});
test('成长记录使用统一的加载、空数据、失败和无权限状态,以及提交中的按钮反馈', () => {
const root = path.join(__dirname, '..');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'growth-pages.js'), 'utf8');
assert.match(script, /function getApiStateType\(error\)[\s\S]*isForbidden\(error\) \? 'forbidden' : 'error'/);
assert.match(script, /setState\(query\('\[data-growth-list\]'\), 'loading', '正在加载成长记录…'\)/);
assert.match(script, /setState\(query\('\[data-growth-detail\]'\), 'loading', '正在加载成长记录详情…'\)/);
assert.match(script, /setFormStatus\('正在读取家谱权限…', 'loading'\)/);
assert.match(script, /setFormStatus\(validation, 'error'\)/);
assert.match(script, /setWritePending\(true, '正在保存…'\)/);
assert.match(script, /setFormStatus\('正在保存成长记录…', 'loading'\)/);
});
+16
View File
@@ -103,6 +103,8 @@ test('帮助中心只把缺失登录态和 401 视为登录失效', () => {
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);
assert.equal(HelpPages.isForbidden({ status: 403 }), true);
assert.equal(HelpPages.isForbidden({ status: 401 }), false);
});
test('帮助中心使用真实 PC 列表和详情,不再展示硬编码问答', () => {
@@ -110,7 +112,21 @@ test('帮助中心使用真实 PC 列表和详情,不再展示硬编码问答'
assert.match(page, /data-help-list/);
assert.match(page, /data-help-status/);
assert.match(page, /data-help-status[^>]*role="status"/);
assert.match(page, /data-api-state="loading"/);
assert.match(page, /public\/js\/help-pages\.js/);
assert.match(page, /public\/js\/profile-common\.js/);
assert.doesNotMatch(page, /如何创建第一本家谱|如何邀请亲人一起维护/);
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
});
test('帮助中心将加载、空数据、失败和无权限写入统一状态组件', () => {
const source = read('public/js/help-pages.js');
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setApiState\(list, 'loading', '正在读取帮助文章…'\)/);
assert.match(source, /setApiState\(list, 'empty', '当前暂无帮助文章。'\)/);
assert.match(source, /setApiState\(panel, 'loading', '正在读取完整解答…'\)/);
assert.match(source, /isForbidden\(error\) \? 'forbidden' : 'error'/);
assert.match(source, /setPendingButton\(refresh, true, '正在刷新…'\)/);
});
+28 -2
View File
@@ -15,14 +15,14 @@ function read(relativePath) {
test('加入申请只构造 AppGenealogyJoinApplyBody 并裁剪可选文本', () => {
assert.deepEqual(JoinPages.buildJoinApplyBody({
applicantName: ' 叶子 ',
phone: ' 19181970173 ',
phone: ' 测试联系方式 ',
relationDesc: ' 族亲 ',
applyReason: ' 申请加入 ',
inviterUserId: 'must-drop',
genealogyId: 'must-drop'
}), {
applicantName: '叶子',
phone: '19181970173',
phone: '测试联系方式',
relationDesc: '族亲',
applyReason: '申请加入'
});
@@ -156,17 +156,43 @@ test('申请加入页由后端家谱选项驱动且不允许手填业务 ID 或
assert.match(source, /data-join-apply-page/);
assert.match(source, /data-join-genealogy-search/);
assert.match(source, /data-join-genealogy-search novalidate aria-describedby="join-options-status"/);
assert.match(source, /data-join-genealogy-options/);
assert.match(source, /data-join-options-status role="status" aria-live="polite"/);
assert.match(source, /data-join-selected-genealogy role="status" aria-live="polite"/);
assert.match(source, /data-join-apply-form/);
assert.match(source, /data-join-apply-form novalidate aria-describedby="join-apply-status"/);
assert.match(source, /name="applicantName"/);
assert.match(source, /name="phone"/);
assert.match(source, /name="relationDesc"/);
assert.match(source, /name="applyReason"/);
assert.match(source, /class="join-page-grid"/);
assert.match(source, /class="join-search-panel"/);
assert.match(source, /class="[^"]*join-apply-form/);
assert.match(source, /<div id="join-apply-status" class="form-note" data-join-apply-status role="status" aria-live="polite"/);
assert.match(source, /public\/js\/profile-common\.js/);
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('public/js/join-pages.js');
const profilePage = read('profile-join-family.html');
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.renderApiState/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /function setOptionsState\(options, type, message\)/);
assert.match(source, /function clearSelectedGenealogy\(\)/);
assert.match(source, /clearSelectedGenealogy\(\);\s+setText\('\[data-join-options-status\]'/);
assert.match(source, /setOptionsState\(options, 'loading', '正在读取可申请家谱…'\)/);
assert.match(source, /\[data-join-options-status\]/);
assert.match(source, /getApiStateType\(error\)/);
assert.match(source, /setSubmitPending\(/);
assert.match(source, /正在提交…/);
assert.match(profilePage, /<div class="form-note" role="status" aria-live="polite" data-my-join-applies-status/);
});
test('我的加入申请页加载真实列表和刷新入口', () => {
const source = read('profile-join-family.html');
+16 -3
View File
@@ -43,7 +43,7 @@ test('待审核列表隐藏内部 ID 和账号手机号,只有管理者可见
genealogyId,
genealogyName: '叶氏家谱',
applicantName: '<叶子>',
phone: '19181970173',
phone: '测试联系方式',
relationDesc: '族亲',
applyReason: '申请加入',
status: '0',
@@ -56,7 +56,7 @@ test('待审核列表隐藏内部 ID 和账号手机号,只有管理者可见
const memberHtml = JoinReviewPages.renderPendingJoinApplies(data, false);
assert.match(managerHtml, /&lt;叶子&gt;/);
assert.match(managerHtml, /19181970173/);
assert.match(managerHtml, /测试联系方式/);
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"/);
@@ -71,12 +71,25 @@ test('审核页开放真实 PC 管理行为且不允许手填业务 ID', () => {
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-apply-list="pending"[^>]*>[\s\S]*data-api-state="loading"/);
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(page, /input name="applyId" type="hidden"/);
assert.doesNotMatch(page, /(?:input|textarea|select)[^>]+name="applyId"[^>]+type="(?:text|number)"/);
assert.doesNotMatch(page, /name="(?:genealogyId|appUserId|inviterUserId|auditUserId)"/);
assert.match(page, /data-join-review-form novalidate/);
assert.match(page, /name="auditRemark"[^>]+maxlength="500"/);
assert.match(page, /data-join-review-form-status/);
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, /审核后申请仍在待审核列表/);
assert.match(script, /function openReviewForm\(applyId, status\)/);
assert.match(script, /setReviewFormStatus\('正在提交审核结果…', 'loading'\)/);
assert.match(script, /function setPendingListState\(message, type\)/);
assert.match(script, /setPendingListState\('当前没有待审核申请', 'empty'\)/);
assert.match(script, /当前账号没有审核该家谱申请的权限。/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.doesNotMatch(script, /root\.prompt/);
});
+16 -1
View File
@@ -23,7 +23,7 @@ test('世系管理页和家谱主页复用同一棵安全树的 HTML', () => {
assert.match(html, /&lt;始祖&gt;/);
assert.match(html, /data-lineage-person="2062179707935264770"/);
assert.doesNotMatch(html, /2062179707935264769/);
assert.match(LineagePages.renderLineageTreeHtml([]), /暂无世系树/);
assert.equal(LineagePages.renderLineageTreeHtml([]), '');
});
test('世系人物表单只构造 Apifox LineagePersonBody 字段', () => {
@@ -301,6 +301,7 @@ test('世系页按 Apifox 关键词范围搜索并提供分页入口', () => {
pageSize: 20
});
assert.match(page, /placeholder="输入姓名、别名或人物编号搜索"/);
assert.match(page, /data-lineage-keyword[^>]+aria-label="按姓名、别名或人物编号搜索成员"/);
assert.match(page, /data-lineage-generation-filter/);
assert.match(page, /data-lineage-status-filter/);
assert.match(page, /data-lineage-pagination/);
@@ -311,6 +312,7 @@ test('世系页按 Apifox 关键词范围搜索并提供分页入口', () => {
test('世系表单为全部 LineagePersonBody 字段提供真实来源且不允许手填业务 ID', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-tree.html'), 'utf8');
const styles = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'profile-module.css'), 'utf8');
const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'lineage-pages.js'), 'utf8');
[
'bindingMode', 'appUserId', 'avatarOssId', 'personNo', 'name', 'aliasName', 'sex',
@@ -319,20 +321,33 @@ test('世系表单为全部 LineagePersonBody 字段提供真实来源且不允
'biography', 'sortOrder', 'remark', 'relationName'
].forEach((name) => assert.match(page, new RegExp('name="' + name + '"')));
assert.match(page, /value="SPECIFIED"(?![^>]+disabled)/);
assert.match(page, /data-lineage-specified-member hidden/);
assert.match(page, /<label for="lineage-specified-member">指定账号<\/label>/);
assert.match(page, /select[^>]+name="appUserId"[^>]+data-lineage-member-options|select[^>]+data-lineage-member-options[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="(?:text|number)"|type="(?:text|number)"[^>]+name="appUserId"/);
assert.doesNotMatch(page, /name="appUserId"[^>]+type="hidden"|type="hidden"[^>]+name="appUserId"/);
assert.match(page, /name="avatarOssId"[^>]+type="hidden"|type="hidden"[^>]+name="avatarOssId"/);
assert.match(page, /data-upload-target="#lineage-avatar-oss-id"/);
assert.match(script, /specifiedMember\.hidden = !specified/);
assert.match(page, /name="generation"[^>]*data-lineage-generation/);
assert.match(page, /name="generationName"[^>]+readonly|readonly[^>]+name="generationName"/);
assert.match(page, /name="fatherId"[^>]*data-lineage-parent-option/);
assert.match(page, /name="motherId"[^>]*data-lineage-parent-option/);
assert.match(page, /data-lineage-management[^>]+hidden/);
assert.match(page, /data-lineage-form novalidate/);
assert.match(page, /data-lineage-submit/);
assert.match(page, /data-lineage-form-status/);
assert.match(page, /data-lineage-action-status/);
assert.match(page, /public\/js\/upload-pages\.js/);
assert.match(styles, /\[data-lineage-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-spouse-field\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
assert.match(styles, /\[data-lineage-pagination\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
assert.match(script, /function setLineageState\(selector, type, message\)/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /setLineageState\('\[data-lineage-list\]', 'loading', '正在加载世系人物…'\)/);
assert.match(script, /setFormStatus\('正在保存成员…', 'loading'\)/);
assert.match(script, /setActionStatus\('正在停用成员…', 'loading'\)/);
assert.match(script, /function setLineageSubmitLabel\(label\)/);
});
test('世系管理的 403 不会被当作登录失效', () => {
+20 -2
View File
@@ -70,6 +70,7 @@ test('成员修改只构造后端可更新字段并收紧角色枚举', () => {
roleType: 'editor',
lineagePersonId: '2060000000000000004'
});
assert.deepEqual(buildMemberUpdateBody({ relationName: ' ' }), {});
assert.equal(validateMemberUpdateBody(buildMemberUpdateBody({ memberName: '名'.repeat(51) })), '成员名称不能超过 50 个字符');
assert.equal(validateMemberUpdateBody(buildMemberUpdateBody({ relationName: '关系'.repeat(51) })), '关系名称不能超过 100 个字符');
assert.throws(
@@ -96,8 +97,10 @@ test('成员管理动作遵守谱主和管理员的后端权限边界', () => {
assert.equal(canRemoveMember('admin', 'admin'), false);
assert.equal(canLeaveGenealogy('owner'), false);
assert.equal(canLeaveGenealogy('member'), true);
assert.equal(canTransferOwner('owner'), true);
assert.equal(canTransferOwner('admin'), false);
assert.equal(canTransferOwner('owner', true), true);
assert.equal(canTransferOwner('owner', false), false);
assert.equal(canTransferOwner('owner', 'true'), false);
assert.equal(canTransferOwner('admin', true), false);
});
test('谱主转移只使用成员列表中的真实目标 memberId', () => {
@@ -160,17 +163,32 @@ test('成员列表展示转义内容并隐藏手机号与内部 ID', () => {
test('家族管理页开放成员维护、退出和谱主转移且不允许手填 ID', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-family-admin.html'), 'utf8');
const script = fs.readFileSync(modulePath, 'utf8');
assert.doesNotMatch(page, /data-feature-status="pending"|pending-pages\.js/);
assert.match(page, /public\/js\/member-admin-pages\.js/);
assert.match(page, /data-member-list/);
assert.match(page, /data-member-form/);
assert.match(page, /data-member-form novalidate/);
assert.match(page, /name="memberName"/);
assert.match(page, /name="relationName"/);
assert.match(page, /name="roleType"/);
assert.match(page, /select[^>]+name="lineagePersonId"|name="lineagePersonId"[^>]+select/);
assert.match(page, /data-member-leave/);
assert.match(page, /data-member-action-status/);
assert.match(page, /select[^>]+name="targetMemberId"|name="targetMemberId"[^>]+select/);
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, /data-owner-transfer-status/);
assert.doesNotMatch(page, /name="(?:memberId|appUserId)"|type="(?:text|number)"[^>]+name="(?:lineagePersonId|targetMemberId)"/);
assert.doesNotMatch(page, /href="profile-data\.html"[^>]*>\s*添加成员/);
assert.match(script, /function setMemberListState\(type, message\)/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /setMemberListState\('loading', '正在加载成员…'\)/);
assert.match(script, /setStatus\('\[data-member-form-status\]', '正在保存成员…', 'loading'\)/);
assert.match(script, /setStatus\('\[data-owner-transfer-status\]', '正在转让谱主…', 'loading'\)/);
assert.match(script, /setStatus\('\[data-member-action-status\]', '正在移出成员…', 'loading'\)/);
assert.match(script, /canTransferOwner\(actorRole, managementAccess\)/);
});
+31 -1
View File
@@ -160,7 +160,7 @@ test('备忘录编辑器拒绝不可重读的停用记录', () => {
assert.equal(MemoPages.isEditableMemo(null), false);
});
test('备忘录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或业务状态', () => {
test('备忘录列表和编辑页按家谱能力开放 PC CRUD且不允许手填 OSS ID 或业务状态', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-memo.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-memo-edit.html'), 'utf8');
@@ -168,17 +168,47 @@ test('备忘录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或业
assert.match(listPage, /data-memo-list/);
assert.match(listPage, /data-memo-detail/);
assert.match(listPage, /data-memo-create-link hidden/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="remindTime" type="datetime-local"/);
assert.match(editPage, /data-memo-form[^>]*novalidate/);
assert.match(editPage, /for="memoTitle">备忘标题<span class="field-required"/);
assert.match(editPage, /name="completed"/);
assert.match(editPage, /value="0"/);
assert.match(editPage, /value="1"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.match(editPage, /data-memo-clear-media/);
assert.match(editPage, /data-memo-form-status role="status" aria-live="polite"/);
assert.match(editPage, /data-memo-editor-placeholder>[\s\S]*api-state--loading" role="status" aria-live="polite" aria-busy="true"/);
assert.doesNotMatch(editPage, /name="(?:mediaOssIds|status)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
assert.match(editPage, /public\/js\/upload-pages\.js/);
assert.match(editPage, /public\/js\/memo-pages\.js/);
assert.match(styles, /\[data-memo-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
test('备忘录只向内容维护者或管理员开放写操作', () => {
const root = path.join(__dirname, '..');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'memo-pages.js'), 'utf8');
assert.equal(MemoPages.canEditRecords({ canEditContent: true }), true);
assert.equal(MemoPages.canEditRecords({ canManage: true }), true);
assert.equal(MemoPages.canEditRecords({ canEditContent: false, canManage: false }), false);
assert.match(script, /await loadCapability\(api, genealogyId\);/);
assert.match(script, /if \(writePending \|\| !canEditContent \|\| !genealogyId/);
assert.match(script, /if \(canEditContent\) \{[\s\S]*data-memo-delete-id/);
});
test('备忘录使用统一的加载、空数据、失败和无权限状态,以及提交中的按钮反馈', () => {
const root = path.join(__dirname, '..');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'memo-pages.js'), 'utf8');
assert.match(script, /function getApiStateType\(error\)[\s\S]*isForbidden\(error\) \? 'forbidden' : 'error'/);
assert.match(script, /setState\(query\('\[data-memo-list\]'\), 'loading', '正在加载备忘录…'\)/);
assert.match(script, /setState\(query\('\[data-memo-detail\]'\), 'loading', '正在加载备忘录详情…'\)/);
assert.match(script, /setFormStatus\('正在读取家谱权限…', 'loading'\)/);
assert.match(script, /setFormStatus\(validation, 'error'\)/);
assert.match(script, /setWritePending\(true, '正在保存…'\)/);
assert.match(script, /setFormStatus\('正在保存备忘录…', 'loading'\)/);
});
+31 -1
View File
@@ -190,7 +190,7 @@ test('功德记录编辑器拒绝不可重读的停用记录', () => {
assert.equal(isEditableMerit(null), false);
});
test('功德录页面开放 PC CRUD 且不制造附件和契约外类型', () => {
test('功德录页面按家谱能力开放 PC CRUD且不制造附件和契约外类型', () => {
const projectRoot = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(projectRoot, 'profile-merit.html'), 'utf8');
const editPage = fs.readFileSync(path.join(projectRoot, 'profile-merit-edit.html'), 'utf8');
@@ -199,6 +199,7 @@ test('功德录页面开放 PC CRUD 且不制造附件和契约外类型', () =>
assert.doesNotMatch(listPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(listPage, /data-merit-list/);
assert.match(listPage, /data-merit-detail/);
assert.match(listPage, /data-merit-create-link hidden/);
assert.doesNotMatch(editPage, /data-feature-status="pending"|pending-pages\.js/);
assert.match(editPage, /name="meritTime" type="datetime-local"/);
assert.match(editPage, /value="donation"/);
@@ -208,9 +209,38 @@ test('功德录页面开放 PC CRUD 且不制造附件和契约外类型', () =>
assert.doesNotMatch(editPage, /value="service"/);
assert.match(editPage, /name="amount" type="number" step="any"/);
assert.match(editPage, /name="sortOrder"/);
assert.match(editPage, /data-merit-form[^>]*novalidate/);
assert.match(editPage, /for="meritName">功德人姓名<span class="field-required"/);
assert.match(editPage, /for="meritTitle">功德标题<span class="field-required"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /data-merit-form-status role="status" aria-live="polite"/);
assert.match(editPage, /data-merit-editor-placeholder>[\s\S]*api-state--loading" role="status" aria-live="polite" aria-busy="true"/);
assert.doesNotMatch(editPage, /mediaOssIds|type="file"|upload-pages\.js/);
assert.match(editPage, /public\/js\/rich-editor\.js/);
assert.match(editPage, /public\/js\/merit-pages\.js/);
assert.match(styles, /\[data-merit-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
test('功德记录只向内容维护者或管理员开放写操作,并在提交前同步富文本', () => {
const script = fs.readFileSync(modulePath, 'utf8');
assert.equal(requireFunction('canEditRecords')({ canEditContent: true }), true);
assert.equal(requireFunction('canEditRecords')({ canManage: true }), true);
assert.equal(requireFunction('canEditRecords')({ canEditContent: false, canManage: false }), false);
assert.match(script, /await loadCapability\(api, genealogyId\);/);
assert.match(script, /if \(writePending \|\| !canEditContent \|\| !genealogyId/);
assert.match(script, /root\.AppRichEditor && root\.AppRichEditor\.syncAll/);
assert.match(script, /if \(canEditContent\) \{[\s\S]*data-merit-delete-id/);
});
test('功德记录使用统一的加载、空数据、失败和无权限状态,以及提交中的按钮反馈', () => {
const script = fs.readFileSync(modulePath, 'utf8');
assert.match(script, /function getApiStateType\(error\)[\s\S]*isForbidden\(error\) \? 'forbidden' : 'error'/);
assert.match(script, /setState\(query\('\[data-merit-list\]'\), 'loading', '正在加载功德记录…'\)/);
assert.match(script, /setState\(query\('\[data-merit-detail\]'\), 'loading', '正在加载功德记录详情…'\)/);
assert.match(script, /setFormStatus\('正在读取家谱权限…', 'loading'\)/);
assert.match(script, /setFormStatus\(validation, 'error'\)/);
assert.match(script, /setWritePending\(true, '正在保存…'\)/);
assert.match(script, /setFormStatus\('正在保存功德记录…', 'loading'\)/);
});
+18
View File
@@ -99,6 +99,12 @@ test('只有未读且正常的通知提供单条已读动作', () => {
assert.equal(NotificationPages.canMarkRead({ readStatus: '0', status: '1' }), false);
});
test('通知请求的 403 显式归类为无权限,而不是登录失效', () => {
assert.equal(NotificationPages.isForbidden({ status: 403 }), true);
assert.equal(NotificationPages.isForbidden({ code: '403' }), true);
assert.equal(NotificationPages.isForbidden({ status: 401 }), false);
});
test('消息页提供筛选、业务详情和单条/全部已读,不显示原始 JSON', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-messages.html'), 'utf8');
@@ -106,8 +112,20 @@ test('消息页提供筛选、业务详情和单条/全部已读,不显示原
assert.match(source, /data-notification-filter/);
assert.match(source, /data-notification-list/);
assert.match(source, /data-notification-detail/);
assert.match(source, /data-notification-status[^>]*role="status"/);
assert.match(source, /data-api-state="loading"/);
assert.match(source, /data-notification-unread-count/);
assert.match(source, /data-notification-read-all/);
assert.match(source, /src="public\/js\/notification-pages\.js"/);
assert.doesNotMatch(source, /src="public\/js\/pending-pages\.js"/);
});
test('消息中心将列表、详情与已读操作写入统一状态组件', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'public/js/notification-pages.js'), 'utf8');
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setNotificationState\(container, 'empty', '当前暂无通知。'\)/);
assert.match(source, /setNotificationState\(query\('\[data-notification-detail\]'\), 'loading', '正在读取通知详情…'\)/);
assert.match(source, /updateStatus\('loading', '正在标记通知为已读…'\)/);
assert.match(source, /isForbidden\(error\) \? 'forbidden' : 'error'/);
});
+57
View File
@@ -9,6 +9,21 @@ function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
test('项目文档不记录测试手机号', () => {
const docsRoot = path.join(root, 'docs');
const phonePattern = /(?:^|[^0-9])1[3-9][0-9]{9}(?![0-9])/;
fs.readdirSync(docsRoot, { recursive: true })
.filter((entry) => entry.endsWith('.md'))
.forEach((entry) => {
assert.doesNotMatch(
fs.readFileSync(path.join(docsRoot, entry), 'utf8'),
phonePattern,
'文档不应包含手机号:' + entry
);
});
});
test('配置始终使用后端提供的 PC 接口地址', () => {
const createConfig = require('../config.js');
const development = createConfig({ location: { hostname: 'localhost', port: '5500' } });
@@ -29,9 +44,12 @@ test('配置始终使用后端提供的 PC 接口地址', () => {
test('PC 对接规划记录本轮由用户指定的 YAML 冻结契约', () => {
const plan = read('docs/PC接口对接规划.md');
const handover = read('docs/交接文档.md');
assert.match(plan, /genealogy-pc-openapi\.yaml` 对接,因此该 YAML 是本轮冻结契约/);
assert.match(plan, /旧 OpenAPI 文件不再作为新增功能依据/);
assert.match(handover, /本文第二至第七节均为当日历史快照/);
assert.match(handover, /不得据此新增、关闭或阻断任何功能/);
});
test('pages do not load scripts for unavailable business APIs', () => {
@@ -73,3 +91,42 @@ test('pages do not load scripts for unavailable business APIs', () => {
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');
});
test('个人中心将参考账户与家谱能力映射到 PC 工作台,未声明项不伪造操作', () => {
const profile = read('profile.html');
const services = read('profile-services.html');
const share = read('profile-share.html');
const familyHome = read('profile-family-home.html');
const content = read('profile-content.html');
const apiClient = read('utils/ApiClient.js');
const verification = read('docs/验证20%记录.md');
const mapping = read('docs/个人中心能力映射.md');
['profile-data.html', 'profile-security.html', 'profile-feedback.html', 'profile-services.html'].forEach((page) => {
assert.match(profile, new RegExp('href="' + page.replace('.', '\\.') + '"'));
});
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.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(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` 只作为产品信息架构参考/);
['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}`);
});
assert.doesNotMatch(mapping, /\/pages\//);
});
+35 -8
View File
@@ -11,15 +11,10 @@ const familyPendingPages = [
];
const contentPendingPages = [
'profile-admin-permissions.html',
'profile-data-reminders.html'
];
const residualPendingPages = [
'profile-share.html'
];
const pendingPages = familyPendingPages.concat(contentPendingPages, residualPendingPages);
const pendingPages = familyPendingPages.concat(contentPendingPages);
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
@@ -47,8 +42,27 @@ test('待开发页面显式加载状态脚本', () => {
});
});
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.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.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"/);
});
test('已接入的家谱业务页面保持真实功能状态', () => {
['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) => {
['profile-families.html', 'profile-create-family.html', 'profile-join-family.html', 'profile-join-review.html', 'profile-family-home.html', 'profile-family-admin.html', 'profile-admin-permissions.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"/);
@@ -61,6 +75,9 @@ test('已接入的家谱业务页面保持真实功能状态', () => {
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.doesNotMatch(read('profile-admin-permissions.html'), /src="public\/js\/pending-pages\.js"/);
assert.match(read('profile-admin-permissions.html'), /href="profile-family-admin\.html" data-genealogy-context-link/);
assert.match(read('profile-join-review.html'), /href="profile-admin-permissions\.html" data-genealogy-context-link/);
assert.match(read('profile-article.html'), /src="public\/js\/article-pages\.js"/);
assert.match(read('profile-article-edit.html'), /src="public\/js\/article-pages\.js"/);
assert.match(read('profile-album.html'), /src="public\/js\/album-pages\.js"/);
@@ -93,11 +110,21 @@ test('待开发的家谱管理页明确放行已接入的字辈入口并透传
});
test('待开发页面仅放行显式标记的可用功能入口', () => {
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-share.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-invite.html' })), true);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: 'profile-feed.html', 'data-feature-link': 'available' })), false);
assert.equal(PageAvailability.shouldBlockLink(createLink({ href: '#section' })), false);
});
test('待开发邀请页允许返回已接入的成员管理', () => {
const invite = read('profile-invite.html');
assert.match(invite, /href="profile-family-admin\.html"[^>]*data-feature-link="available"[^>]*data-genealogy-context-link/);
assert.equal(PageAvailability.shouldBlockLink(createLink({
href: 'profile-family-admin.html',
'data-feature-link': 'available'
})), false);
});
test('内容入口明确保留家族圈动态跳转', () => {
assert.match(read('profile-content.html'), /href="profile-feed\.html" data-feature-link="available"/);
assert.match(read('profile-content.html'), /href="profile-growth\.html" data-feature-link="available" data-genealogy-context-link/);
+62
View File
@@ -0,0 +1,62 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const ArticlePages = require('../public/js/article-pages.js');
const AlbumPages = require('../public/js/album-pages.js');
const CeremonyPages = require('../public/js/ceremony-admin-pages.js');
const FeedPages = require('../public/js/feed-pages.js');
const GenerationPages = require('../public/js/generation-pages.js');
const GrowthPages = require('../public/js/growth-pages.js');
const LineagePages = require('../public/js/lineage-pages.js');
const MemoPages = require('../public/js/memo-pages.js');
const MeritPages = require('../public/js/merit-pages.js');
const RelativePages = require('../public/js/relative-pages.js');
const VideoPages = require('../public/js/video-pages.js');
const contentCapabilities = [
['谱文', ArticlePages.canEditArticles],
['相册', AlbumPages.canEditAlbums],
['仪式', CeremonyPages.canEditCeremonies],
['动态', FeedPages.canEditFeeds],
['字辈', GenerationPages.canEditGenerations],
['成长记录', GrowthPages.canEditRecords],
['备忘录', MemoPages.canEditRecords],
['功德簿', MeritPages.canEditRecords],
['亲属记录', RelativePages.canEditRecords],
['视频', VideoPages.canEditVideos]
];
test('内容模块只信任家谱详情返回的严格编辑能力', () => {
contentCapabilities.forEach(function (entry) {
const label = entry[0];
const canEdit = entry[1];
assert.equal(typeof canEdit, 'function', label + '缺少编辑能力判断');
assert.equal(canEdit({ canEditContent: true }), true, label + '应允许内容编辑者写入');
assert.equal(canEdit({ canManage: true }), true, label + '应允许家谱管理者写入');
assert.equal(canEdit({ canEditContent: 'true' }), false, label + '不能把字符串权限当作授权');
assert.equal(canEdit({ canManage: 1 }), false, label + '不能把数值权限当作授权');
assert.equal(canEdit({ canEditContent: false, canManage: false }), false, label + '不能向无权限成员开放写入');
});
});
test('世系编辑与成员、入谱审核分别收紧到正式能力字段', () => {
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: true, canManage: false }), {
canEditContent: true
});
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: false, canManage: true }), {
canEditContent: false
});
assert.deepEqual(LineagePages.normalizeLineageAccess({ canEditContent: 'true' }), {
canEditContent: false
});
const memberScript = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'member-admin-pages.js'), 'utf8');
const reviewScript = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'join-review-pages.js'), 'utf8');
assert.match(memberScript, /managementAccess = detail\.canManage === true;/);
assert.match(memberScript, /function canTransferOwner\(role, canManage\)[\s\S]+?role === 'owner' && canManage === true/);
assert.match(reviewScript, /canManage = detail && detail\.canManage === true;/);
});
+96 -4
View File
@@ -10,18 +10,21 @@ const UploadPages = require('../public/js/upload-pages.js');
test('profile view reads only the documented profile fields', () => {
assert.deepEqual(ProfilePages.buildProfileView({
nickName: '小林',
realName: '林某',
phone: '13800000000',
birthday: '1990-01-01',
provinceCode: '11',
userName: '不应读取',
mobile: '不应读取'
}), {
displayName: '小林',
realName: '林某',
avatarText: '小',
avatarStatus: '未设置',
phone: '13800000000',
sex: '待填写',
birthday: '1990-01-01',
regionText: '加载中...'
email: '待填写',
regionText: '当前资料接口未提供'
});
assert.deepEqual(ProfilePages.buildProfileView({
@@ -30,20 +33,27 @@ test('profile view reads only the documented profile fields', () => {
mobile: '不应读取'
}), {
displayName: '未设置昵称',
realName: '待填写',
avatarText: '家',
avatarStatus: '未设置',
phone: '未绑定',
sex: '待填写',
birthday: '待填写',
regionText: '待填写'
email: '待填写',
regionText: '当前资料接口未提供'
});
});
test('profile loading failure has an explicit non-demo view', () => {
assert.deepEqual(ProfilePages.buildUnavailableProfileView(), {
displayName: '账户资料暂时无法读取',
realName: '暂无数据',
avatarText: '',
avatarStatus: '暂无数据',
phone: '暂无数据',
birthday: '暂无数据',
email: '暂无数据',
sex: '暂无数据',
regionText: '暂无数据'
});
});
@@ -70,9 +80,23 @@ test('profile update emits only the six fields defined by ProfileUpdateBody', ()
ProfilePages.buildProfileUpdateBody({ avatar: '2060000000000000001' }),
{ avatar: '2060000000000000001' }
);
assert.throws(() => ProfilePages.buildProfileUpdateBody({ avatar: 'not-an-id' }), /头像 OSS ID 必须是整数/);
assert.equal(UploadPages.normalizeUploadResult({ ossId: '2060000000000000000' }).ossId, '2060000000000000000');
});
test('个人中心概览不读取资料接口未声明的地区字段', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'profile-pages.js'), 'utf8');
assert.match(source, /regionText: '当前资料接口未提供'/);
assert.doesNotMatch(source, /provinceCode|cityCode|districtCode|regionPath|applyProfileRegionSelection/);
});
test('profile update rejects an empty payload and an invalid email before requesting the PC API', () => {
assert.equal(ProfilePages.validateProfileUpdateBody({}), '请至少填写一项资料');
assert.equal(ProfilePages.validateProfileUpdateBody({ email: 'invalid-email' }), '请输入正确的邮箱地址');
assert.equal(ProfilePages.validateProfileUpdateBody({ nickName: '小林' }), '');
});
test('region selector accepts only documented region fields and levels', () => {
assert.deepEqual(RegionPages.normalizeList({ data: [{ regionCode: '11' }] }), []);
assert.equal(RegionPages.getRegionCode({ value: '11' }), '');
@@ -93,7 +117,10 @@ test('region selector accepts only documented region fields and levels', () => {
test('profile data page exposes every editable ProfileUpdateBody field without a manual OSS ID input', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-data.html'), 'utf8');
assert.match(source, /data-profile-form novalidate/);
assert.match(source, /data-profile-form/);
assert.match(source, /data-profile-text="realName"/);
assert.match(source, /name="nickName"/);
assert.match(source, /name="realName"/);
assert.match(source, /name="avatar"/);
@@ -104,7 +131,72 @@ test('profile data page exposes every editable ProfileUpdateBody field without a
assert.match(source, /name="avatar"[\s\S]*type="hidden"|type="hidden"[\s\S]*name="avatar"/);
assert.doesNotMatch(source, /data-region-profile-form/);
assert.doesNotMatch(source, /data-region-profile-status/);
assert.match(source, /data-region-search-form/);
assert.match(source, /role="group" aria-labelledby="profile-region-heading" aria-describedby="profile-region-contract-note profile-region-picker-status"/);
assert.match(source, /<label for="profileProvinceCode">省/);
assert.match(source, /<label for="profileCityCode">市/);
assert.match(source, /<label for="profileDistrictCode">区县/);
assert.match(source, /id="profile-region-contract-note"/);
assert.match(source, /id="profile-region-picker-status"[^>]*data-region-picker-status role="status" aria-live="polite"/);
assert.match(source, /data-region-search-form[^>]*novalidate/);
assert.match(source, /id="profileRegionKeyword"[^>]*aria-describedby="profile-region-search-status"[^>]*required/);
assert.match(source, /data-region-search-status role="status" aria-live="polite"/);
assert.match(source, /src="public\/js\/upload-pages\.js"/);
assert.match(source, /src="public\/js\/region-pages\.js"/);
assert.match(source, /data-profile-load-status/);
assert.match(source, /data-profile-status[^>]*role="status"/);
assert.match(source, /data-genealogy-context-link/);
assert.doesNotMatch(source, /第二代 顺字辈|微信 \/ QQ|学历 \/ 职业|父亲<\/span><b>某某/);
});
test('地区搜索复用统一状态组件并区分空结果、失败和无权限', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'region-pages.js'), 'utf8');
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setApiState\(results, 'loading', '正在搜索地区…'\)/);
assert.match(source, /setApiState\(container, 'empty', '未找到匹配地区'\)/);
assert.match(source, /当前账号无权搜索地区。/);
assert.match(source, /当前账号无权定位地区。/);
assert.match(source, /当前账号无权读取地区数据。/);
assert.match(source, /setPickerState\(picker, 'loading', '正在加载省…'\)/);
assert.match(source, /setPickerState\(picker, 'loading', '正在加载市…'\)/);
assert.match(source, /setPickerState\(picker, 'loading', '正在加载区县…'\)/);
assert.equal(RegionPages.isForbidden({ status: 403 }), true);
assert.equal(RegionPages.isForbidden({ status: 401 }), false);
});
test('个人资料页不伪造亲属数据,并明确指向当前家谱的成员管理', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'profile-data.html'), 'utf8');
assert.match(source, /当前 PC 资料接口未提供个人亲属关系列表/);
assert.match(source, /href="profile-family-admin\.html"[^>]*data-genealogy-context-link/);
assert.match(source, /api-state--empty/);
assert.doesNotMatch(source, /张三|父亲 · 已绑定成员资料|邀请激活/);
});
test('个人中心以桌面服务面板承接 APP 的账户功能,并隔离家谱业务入口', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'profile.html'), 'utf8');
const main = source.slice(0, source.indexOf('</main>'));
assert.match(main, /<h2>账户与服务<\/h2>/);
[
'profile-messages.html',
'profile-data.html',
'profile-security.html',
'profile-services.html',
'profile-feedback.html',
'app.html'
].forEach((href) => {
assert.match(main, new RegExp('href="' + href.replace('.', '\\.') + '"'));
});
assert.doesNotMatch(main, /href="profile-(?:article|album|video|merit|gift|join-review)\.html"/);
});
test('个人中心首页概览只展示已声明的账户资料字段', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'profile.html'), 'utf8');
['phone', 'displayName', 'realName', 'sex', 'birthday', 'email'].forEach((field) => {
assert.match(source, new RegExp('data-profile-text="' + field + '"'));
});
assert.doesNotMatch(source, /data-profile-text="regionText"/);
assert.match(source, /<div class="profile-load-status" data-profile-load-status role="status" aria-live="polite">/);
});
+31 -1
View File
@@ -180,7 +180,7 @@ test('亲友记录编辑器拒绝不可重读的停用记录', () => {
assert.equal(RelativePages.isEditableRecord(null), false);
});
test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或状态', () => {
test('亲友记录列表和编辑页按家谱能力开放 PC CRUD且不允许手填 OSS ID 或状态', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-relative.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-relative-edit.html'), 'utf8');
@@ -188,10 +188,15 @@ test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或
assert.match(listPage, /data-relative-list/);
assert.match(listPage, /data-relative-detail/);
assert.match(listPage, /data-relative-create-link hidden/);
assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/);
assert.match(editPage, /name="eventTime" type="datetime-local"/);
assert.match(editPage, /data-relative-form[^>]*novalidate/);
assert.match(editPage, /for="relativeName">亲友姓名<span class="field-required"/);
assert.match(editPage, /name="status" type="hidden" value="0"/);
assert.match(editPage, /name="mediaOssIds" type="hidden"/);
assert.match(editPage, /data-relative-form-status role="status" aria-live="polite"/);
assert.match(editPage, /data-relative-editor-placeholder>[\s\S]*api-state--loading" role="status" aria-live="polite" aria-busy="true"/);
assert.match(editPage, /data-relative-clear-media/);
assert.doesNotMatch(editPage, /name="(?:mediaOssIds|status)"[^>]*type="text"/);
assert.match(editPage, /public\/js\/md5\.js/);
@@ -199,3 +204,28 @@ test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或
assert.match(editPage, /public\/js\/relative-pages\.js/);
assert.match(styles, /\[data-relative-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s);
});
test('亲友记录只向内容维护者或管理员开放写操作', () => {
const root = path.join(__dirname, '..');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'relative-pages.js'), 'utf8');
assert.equal(RelativePages.canEditRecords({ canEditContent: true }), true);
assert.equal(RelativePages.canEditRecords({ canManage: true }), true);
assert.equal(RelativePages.canEditRecords({ canEditContent: false, canManage: false }), false);
assert.match(script, /await loadCapability\(api, genealogyId\);/);
assert.match(script, /if \(writePending \|\| !canEditContent \|\| !genealogyId/);
assert.match(script, /if \(canEditContent\) \{[\s\S]*data-relative-delete-id/);
});
test('亲友记录使用统一的加载、空数据、失败和无权限状态,以及提交中的按钮反馈', () => {
const root = path.join(__dirname, '..');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'relative-pages.js'), 'utf8');
assert.match(script, /function getApiStateType\(error\)[\s\S]*isForbidden\(error\) \? 'forbidden' : 'error'/);
assert.match(script, /setState\(query\('\[data-relative-list\]'\), 'loading', '正在加载亲友记录…'\)/);
assert.match(script, /setState\(query\('\[data-relative-detail\]'\), 'loading', '正在加载亲友记录详情…'\)/);
assert.match(script, /setFormStatus\('正在读取家谱权限…', 'loading'\)/);
assert.match(script, /setFormStatus\(validation, 'error'\)/);
assert.match(script, /setWritePending\(true, '正在保存…'\)/);
assert.match(script, /setFormStatus\('正在保存亲友记录…', 'loading'\)/);
});
+27
View File
@@ -190,3 +190,30 @@ test('security page field names match phone-change and account-deactivation DTOs
assert.match(source, /id="deactivateSmsCode"\s+name="smsCode"/);
assert.match(source, /data-security-send-deactivate-code/);
});
test('上传状态复用统一组件并区分无权限', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'upload-pages.js'), 'utf8');
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setUploadStatus\(status, 'loading', files\[index\]\.name \+ ' 正在上传…'\)/);
assert.match(source, /当前账号无权上传文件。/);
assert.match(source, /status\.setAttribute\('role', 'status'\)/);
assert.equal(UploadPages.isForbidden({ status: 403 }), true);
assert.equal(UploadPages.isForbidden({ status: 401 }), false);
});
test('security forms provide visible required labels and unified validation, loading and failure feedback', () => {
const page = fs.readFileSync(path.join(__dirname, '..', 'profile-security.html'), 'utf8');
const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'security-pages.js'), 'utf8');
assert.match(page, /data-security-form="password" novalidate/);
assert.match(page, /data-security-form="phone" novalidate/);
assert.match(page, /data-security-form="deactivate" novalidate/);
assert.match(page, /field-required/);
assert.match(page, /data-security-status[^>]*role="status"/);
assert.match(page, /data-security-bound-phone[^>]*role="status"/);
assert.doesNotMatch(page, /微信快捷登录|账号安全提醒/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /function setBoundPhoneStatus\(type, message\)/);
assert.match(script, /setStatus\(form, 'error', '验证码发送失败,请稍后重试。'\)/);
});
+20 -3
View File
@@ -74,7 +74,7 @@ test('站点资讯列表使用响应 ID 生成详情链接并隐藏内部字段'
assert.match(html, /&lt;script&gt;/);
assert.match(html, /&lt;img src=x&gt;/);
assert.doesNotMatch(html, /<script|<img|secret-oss|secret-remark|coverOssId|sortOrder|status|remark/);
assert.match(SiteNewsPages.renderArticleList([]), /当前暂无资讯/);
assert.equal(SiteNewsPages.renderArticleList([]), '');
});
test('站点资讯详情转义正文、保留换行并只开放安全外链', () => {
@@ -159,19 +159,36 @@ test('详情 URL 只读取安全字符串文章 ID', () => {
assert.equal(SiteNewsPages.readArticleId(''), '');
});
test('资讯列表和详情将加载、空数据、失败及无权限写入统一状态组件', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../public/js/site-news-pages.js'), 'utf8');
assert.equal(SiteNewsPages.isForbidden({ status: 403 }), true);
assert.equal(SiteNewsPages.isForbidden({ status: 401 }), false);
assert.equal(SiteNewsPages.isMissingArticleError(new Error('资讯不存在或已下线')), true);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setSiteState\(list, 'loading', '正在读取资讯…'\)/);
assert.match(source, /setSiteState\(detail, 'loading', '正在读取资讯详情…'\)/);
assert.match(source, /isForbidden\(error\) \? 'forbidden' : 'error'/);
assert.match(source, /isForbidden\(error\) \? 'forbidden' : \(isMissingArticleError\(error\) \? 'empty' : 'error'\)/);
});
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, /data-site-news-status[^>]*role="status"/);
assert.match(newsPage, /data-api-state="loading"/);
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(newsPage, /src="public\/js\/profile-common\.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, /data-site-article-status[^>]*role="status"/);
assert.match(detailPage, /data-api-state="loading"/);
assert.match(detailPage, /src="public\/js\/profile-common\.js"/);
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"/);
+8 -1
View File
@@ -18,6 +18,13 @@ test('内容发布页为全部家谱功能入口透传真实家谱上下文', ()
});
});
test('内容工作台在未选择家谱时使用统一的空上下文状态', () => {
const page = read('profile-content.html');
assert.match(page, /api-state--empty[^>]*data-api-state="empty"/);
assert.doesNotMatch(page, /api-empty/);
});
test('家谱主页公开谱文、相册、视频、功德和祭祀活动入口并透传上下文', () => {
const page = read('profile-family-home.html');
@@ -66,6 +73,6 @@ test('个人中心、我的家谱和当前家谱主页使用明确且可返回
assert.match(account, /<a href="profile-families\.html">我的家谱<\/a>/);
assert.match(families, /<nav class="profile-path" aria-label="当前位置">[\s\S]*个人中心[\s\S]*我的家谱/);
assert.match(familyHome, /<nav class="profile-path" aria-label="当前位置">[\s\S]*profile-families\.html[\s\S]*data-family-home-context/);
assert.match(familyHomeRuntime, /setText\('\[data-family-home-context\]', result\.overview\.genealogyName\)/);
assert.match(familyHomeRuntime, /setText\('\[data-family-home-context\]', result\.genealogyName\)/);
assert.doesNotMatch(coreHeaders, /返回中心|数字家谱/);
});
+35
View File
@@ -129,6 +129,15 @@ test('视频文件元数据时长按秒取整且拒绝无效值', () => {
assert.equal(VideoPages.normalizeDurationSeconds(Infinity), undefined);
});
test('视频维护资格只接受服务端返回的内容或管理布尔能力', () => {
const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'video-pages.js'), 'utf8');
assert.equal(VideoPages.canEditVideos({ canEditContent: true }), true);
assert.equal(VideoPages.canEditVideos({ canManage: true }), true);
assert.equal(VideoPages.canEditVideos({ canEditContent: 'true', canManage: false }), false);
assert.match(script, /setManagementAccess\(canEditVideos\(result\[0\]\)\)/);
});
test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-video.html'), 'utf8');
@@ -141,6 +150,9 @@ test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID',
assert.match(listPage, /data-video-management hidden/);
assert.match(listPage, /src="public\/js\/video-pages\.js"/);
assert.match(editPage, /data-video-form data-video-management hidden/);
assert.match(editPage, /data-video-form data-video-management hidden novalidate/);
assert.match(editPage, /class="field-required"/);
assert.match(editPage, /<div class="form-status" data-video-form-status/);
assert.match(editPage, /name="videoTitle"/);
assert.match(editPage, /name="videoDesc"/);
assert.match(editPage, /name="coverOssId" type="hidden"/);
@@ -152,3 +164,26 @@ test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID',
assert.match(editPage, /public\/js\/video-pages\.js/);
assert.match(styles, /\[data-video-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s);
});
test('视频编辑将权限读取、校验、保存中和失败状态接入公共状态组件', () => {
const root = path.join(__dirname, '..');
const listPage = fs.readFileSync(path.join(root, 'profile-video.html'), 'utf8');
const editPage = fs.readFileSync(path.join(root, 'profile-video-edit.html'), 'utf8');
const script = fs.readFileSync(path.join(root, 'public', 'js', 'video-pages.js'), 'utf8');
assert.match(listPage, /data-video-list[^>]*>[\s\S]*data-api-state="loading"/);
assert.match(listPage, /data-video-detail[^>]*>[\s\S]*data-api-state="empty"/);
assert.match(editPage, /data-api-state="loading"[^>]*data-video-permission-placeholder/);
assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(script, /function setPermissionPlaceholder\(type, message\)/);
assert.match(script, /setVideoListState\('loading', '正在加载视频…'\)/);
assert.match(script, /setVideoDetailState\('loading', '正在加载视频详情…'\)/);
assert.match(script, /setVideoListState\('empty', '暂无家族视频'\)/);
assert.match(script, /setVideoListState\('forbidden', '当前账号无权查看该家谱视频。'\)/);
assert.match(script, /setVideoDetailState\(\n\s*getApiStateType\(error\)/);
assert.match(script, /正在读取家谱权限…', 'loading'/);
assert.match(script, /setPermissionPlaceholder\('forbidden', '当前账号没有视频维护权限。'\)/);
assert.match(script, /setFormStatus\(validation, 'error'\)/);
assert.match(script, /setWritePending\(true, '正在保存…'\)/);
assert.match(script, /getApiStateType\(error\)/);
});
+41
View File
@@ -112,6 +112,16 @@ test('家谱选择项只接受响应中的稳定 ID 和名称', () => {
genealogyId,
genealogyName: ''
}), null);
assert.deepEqual(VipPages.normalizeGenealogyOptions([
{ genealogyId, genealogyName: '叶氏家谱', surname: '叶' },
{ genealogyId: 'unsafe', genealogyName: '无效家谱' }
]), [{ genealogyId, genealogyName: '叶氏家谱', surname: '叶' }]);
assert.equal(VipPages.resolveContextGenealogyId([
{ genealogyId, genealogyName: '叶氏家谱', surname: '叶' }
], genealogyId), genealogyId);
assert.equal(VipPages.resolveContextGenealogyId([
{ genealogyId, genealogyName: '叶氏家谱', surname: '叶' }
], 'unsafe'), '');
});
test('订单请求只构造 AppVipOrderBody 且页面默认省略支付方式', () => {
@@ -216,9 +226,27 @@ test('会员服务页只从接口结果选择套餐和家谱,不暴露业务 I
assert.match(source, /<select[^>]+data-vip-genealogy-options/);
assert.match(source, /data-vip-order-refresh/);
assert.match(source, /data-vip-order-list/);
assert.match(source, /data-vip-order-form novalidate/);
assert.match(source, /data-vip-order-status[^>]*role="status"/);
assert.match(source, /data-api-state="loading"/);
assert.doesNotMatch(source, /<input[^>]+name="(?:packageId|genealogyId|orderId)"/);
assert.doesNotMatch(source, /name="payType"/);
assert.doesNotMatch(source, /立即支付|模拟支付|取消订单|退款/);
assert.ok(source.indexOf('src="public/js/profile-common.js"') < source.indexOf('src="public/js/vip-pages.js"'));
});
test('会员服务页补齐桌面端设置与协议入口,不伪造账户接口', () => {
const source = fs.readFileSync(path.join(root, 'profile-services.html'), 'utf8');
assert.match(source, /平台设置与协议/);
assert.match(source, /href="about\.html"/);
assert.match(source, /href="privacy\.html"/);
assert.match(source, /href="children-privacy\.html"/);
assert.match(source, /href="terms\.html"/);
assert.match(source, /个性化推荐/);
assert.match(source, /当前 PC 接口未提供推荐偏好的读取或保存/);
assert.doesNotMatch(source, /data-setting-form|data-withdraw-form|data-share-reward/);
assert.doesNotMatch(source, /data-recommendation-toggle|name="recommendation"/);
});
test('会员服务运行时并行读取套餐、我的家谱和订单,并在创建后重新读取核验', () => {
@@ -229,3 +257,16 @@ test('会员服务运行时并行读取套餐、我的家谱和订单,并在
assert.match(source, /refreshed = normalizeVipOrders\(await api\.vipOrders\(\)\)/);
assert.match(source, /item\.orderId === created\.orderId/);
});
test('会员服务将加载、空数据、提交校验与请求失败写入统一状态组件', () => {
const source = fs.readFileSync(path.join(root, 'public/js/vip-pages.js'), 'utf8');
assert.match(source, /function setApiState\(target, type, message\)/);
assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/);
assert.match(source, /setListState\(packageList, 'loading', '正在读取会员套餐…'\)/);
assert.match(source, /setListState\(orderList, 'empty', '当前没有会员订单。'\)/);
assert.match(source, /setStatus\('error', validation\)/);
assert.match(source, /setPendingButton\(submit, true, '正在创建订单…'\)/);
assert.match(source, /root\.ProfileUI && typeof root\.ProfileUI\.getGenealogyId === 'function'/);
assert.match(source, /已关联当前家谱,请选择会员套餐/);
});