feat: 完善对象存储上传与真实条码二维码引擎,彻底剥离保底Mock数据
这个提交包含在:
@@ -90,4 +90,30 @@ describe('ActivityListContent (TDD 测试套件: 活动大厅陪吃·陪唱·陪
|
||||
expect(content).toMatch(/(下午茶陪喝|陪喝)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 活动大厅综合搜索框功能与双主题规范门禁 (TDD)', () => {
|
||||
it('页面必须包含顶部综合搜索框组件并支持关键词双向绑定', () => {
|
||||
const content = fs.readFileSync(listVuePath, 'utf-8');
|
||||
expect(content).toContain('header-search');
|
||||
expect(content).toContain('search-input');
|
||||
expect(content).toContain('searchKeyword');
|
||||
});
|
||||
|
||||
it('搜索过滤逻辑必须同时支持标题、商圈地点、品类与局长多维度匹配', () => {
|
||||
const content = fs.readFileSync(listVuePath, 'utf-8');
|
||||
// 必须包含基于 searchKeyword 的模糊匹配逻辑
|
||||
expect(content).toContain('matchKeyword');
|
||||
expect(content).toMatch(/a\.title.*includes/);
|
||||
expect(content).toMatch(/a\.location.*includes/);
|
||||
expect(content).toMatch(/a\.creatorNickname.*includes/);
|
||||
});
|
||||
|
||||
it('搜索框在 theme-warm 浅色暖阳晨曦模式下必须适配纯白背景与深色文本', () => {
|
||||
const content = fs.readFileSync(listVuePath, 'utf-8');
|
||||
const warmSection = content.split('.activity-container.theme-warm')[1] || '';
|
||||
expect(warmSection).toContain('.header-search');
|
||||
expect(warmSection).toContain('.search-box');
|
||||
expect(warmSection).toContain('#FFFFFF');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,5 +192,205 @@ describe('ActivityService (TDD 测试套件: 活动项目与4位核销码)', ()
|
||||
expect(check.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 房主手动调价与加赏加急机制 (提价/降价与已报名成员差额补偿)', () => {
|
||||
it('发单500元/人招募4人,2人报名后房主手动提价至800元/人,全员同享新高价差额', () => {
|
||||
// 1. 房主发起4人单,每人500元 (50000分)
|
||||
const activity = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '【高端陪玩】四排吃鸡车队高额赏金局',
|
||||
gameCategory: '热门电竞',
|
||||
payType: ActivityPayType.PROJECT_PAY,
|
||||
perPersonFeeCents: 50000,
|
||||
maxParticipants: 4,
|
||||
allowDynamicPrice: true,
|
||||
priceAdjustRule: 'UNIFIED_HIGH',
|
||||
});
|
||||
|
||||
// 2. 两人报名成功
|
||||
const p1 = activityService.joinActivity(activity.id, { userId: 2001, payAmountCents: 50000 });
|
||||
const p2 = activityService.joinActivity(activity.id, { userId: 2002, payAmountCents: 50000 });
|
||||
expect(activity.currentParticipants).toBe(2);
|
||||
expect(p1.payAmountCents).toBe(50000);
|
||||
expect(p2.payAmountCents).toBe(50000);
|
||||
|
||||
// 3. 房主提价至 800元/人 (80000分) 加急招募
|
||||
const result = activityService.adjustActivityPrice({
|
||||
activityId: activity.id,
|
||||
operatorId: 1001,
|
||||
newPriceCents: 80000,
|
||||
});
|
||||
|
||||
expect(result.priceDiffCents).toBe(30000); // 每人加赏300元
|
||||
expect(result.affectedParticipantsCount).toBe(2);
|
||||
expect(result.activity.perPersonFeeCents).toBe(80000);
|
||||
expect(result.activity.totalFeeCents).toBe(320000); // 800 * 4 = 3200元
|
||||
expect(p1.payAmountCents).toBe(80000);
|
||||
expect(p2.payAmountCents).toBe(80000);
|
||||
|
||||
// 4. 第3位新加入的成员直接按最新高价800元报名
|
||||
const p3 = activityService.joinActivity(activity.id, { userId: 2003, payAmountCents: 80000 });
|
||||
expect(p3.payAmountCents).toBe(80000);
|
||||
expect(activity.currentParticipants).toBe(3);
|
||||
});
|
||||
|
||||
it('非房主尝试调价应抛出权限异常', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '测试活动',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.PROJECT_PAY,
|
||||
perPersonFeeCents: 50000,
|
||||
maxParticipants: 4,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
activityService.adjustActivityPrice({
|
||||
activityId: act.id,
|
||||
operatorId: 2002, // 非房主
|
||||
newPriceCents: 80000,
|
||||
});
|
||||
}).toThrow('只有发起人有权调整活动出价');
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. 房主解散与取消组局 (0人报名秒撤 / 有人报名退款全员)', () => {
|
||||
it('0人报名时,房主可直接解散取消组局', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '测试酸汤火锅局',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 5000,
|
||||
maxParticipants: 4,
|
||||
});
|
||||
|
||||
const result = activityService.cancelActivity({
|
||||
activityId: act.id,
|
||||
operatorId: 1001,
|
||||
reason: '临时有事改期',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('CANCELLED');
|
||||
expect(result.refundedCount).toBe(0);
|
||||
expect(result.totalRefundCents).toBe(0);
|
||||
});
|
||||
|
||||
it('已有搭子报名时,房主解散将全额退还已报名成员款项', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '三里屯火锅局',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 5000,
|
||||
maxParticipants: 4,
|
||||
});
|
||||
|
||||
// 两位搭子报名付款
|
||||
activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 5000 });
|
||||
activityService.joinActivity(act.id, { userId: 2002, payAmountCents: 5000 });
|
||||
|
||||
const result = activityService.cancelActivity({
|
||||
activityId: act.id,
|
||||
operatorId: 1001,
|
||||
reason: '突发暴雨改期',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('CANCELLED');
|
||||
expect(result.refundedCount).toBe(2);
|
||||
expect(result.totalRefundCents).toBe(10000); // 5000 * 2 = 10000分
|
||||
expect(result.refundDetails).toEqual([
|
||||
{ userId: 2001, refundAmountCents: 5000 },
|
||||
{ userId: 2002, refundAmountCents: 5000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('非房主尝试解散组局应抛出权限异常', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '测试活动',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 5000,
|
||||
maxParticipants: 4,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
activityService.cancelActivity({
|
||||
activityId: act.id,
|
||||
operatorId: 2005, // 非房主
|
||||
});
|
||||
}).toThrow('只有发起人有权解散此组局');
|
||||
});
|
||||
|
||||
it('已核销或已完成的活动不可再被解散', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '测试活动',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 5000,
|
||||
maxParticipants: 2,
|
||||
});
|
||||
const pt = activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 5000 });
|
||||
activityService.verifyCheckin({ activityId: act.id, operatorId: 1001, verifyCode: pt.verifyCode });
|
||||
|
||||
expect(() => {
|
||||
activityService.cancelActivity({
|
||||
activityId: act.id,
|
||||
operatorId: 1001,
|
||||
});
|
||||
}).toThrow('聚会已有成员到场核销,不可随意解散');
|
||||
});
|
||||
});
|
||||
|
||||
describe('7. 已报名成员主动退席与名额释放', () => {
|
||||
it('成员在未核销前可主动退席,席位人数自动回退', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '四人拼桌火锅',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 6000,
|
||||
maxParticipants: 4,
|
||||
});
|
||||
|
||||
activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 6000 });
|
||||
activityService.joinActivity(act.id, { userId: 2002, payAmountCents: 6000 });
|
||||
expect(act.currentParticipants).toBe(2);
|
||||
|
||||
const leaveResult = activityService.leaveActivity({
|
||||
activityId: act.id,
|
||||
userId: 2001,
|
||||
});
|
||||
|
||||
expect(leaveResult.success).toBe(true);
|
||||
expect(leaveResult.refundAmountCents).toBe(6000);
|
||||
expect(act.currentParticipants).toBe(1);
|
||||
});
|
||||
|
||||
it('已核销到场的成员不可退席', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: 1001,
|
||||
title: '四人拼桌火锅',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 6000,
|
||||
maxParticipants: 4,
|
||||
});
|
||||
|
||||
const pt = activityService.joinActivity(act.id, { userId: 2001, payAmountCents: 6000 });
|
||||
activityService.verifyCheckin({ activityId: act.id, operatorId: 1001, verifyCode: pt.verifyCode });
|
||||
|
||||
expect(() => {
|
||||
activityService.leaveActivity({
|
||||
activityId: act.id,
|
||||
userId: 2001,
|
||||
});
|
||||
}).toThrow('已核销到场,无法申请退席');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vm from 'vm';
|
||||
|
||||
function loadFrontendEsm(filePath: string) {
|
||||
let code = fs.readFileSync(filePath, 'utf-8');
|
||||
code = code.replace(/export\s+const\s+(\w+)/g, 'const $1');
|
||||
code = code.replace(/export\s+class\s+(\w+)/g, 'class $1');
|
||||
code = code.replace(/export\s+function\s+(\w+)/g, 'function $1');
|
||||
const sandbox: any = { module: { exports: {} }, exports: {} };
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(
|
||||
`${code}\nmodule.exports = { ...module.exports, ...(typeof encodeCode128B !== 'undefined' ? { encodeCode128B, getBarcodeBits } : {}), ...(typeof generateQRCodeMatrix !== 'undefined' ? { generateQRCodeMatrix, QRCodeModel } : {}) };`,
|
||||
sandbox
|
||||
);
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
describe('Barcode & QRCode Architecture (TDD 门票真实条形码与二维码生成及零Mock门禁)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
const barcodeJsPath = path.join(rootDir, 'pwuniapp/common/barcode.js');
|
||||
const qrcodeJsPath = path.join(rootDir, 'pwuniapp/common/qrcode.js');
|
||||
const ticketVuePath = path.join(rootDir, 'pwuniapp/pages/activity/my-ticket.vue');
|
||||
|
||||
describe('1. Code128 条形码算法规范', () => {
|
||||
it('barcode.js 文件必须存在且导出 encodeCode128B 和 getBarcodeBits', () => {
|
||||
expect(fs.existsSync(barcodeJsPath)).toBe(true);
|
||||
const content = fs.readFileSync(barcodeJsPath, 'utf-8');
|
||||
expect(content).toContain('export function encodeCode128B');
|
||||
expect(content).toContain('export function getBarcodeBits');
|
||||
});
|
||||
|
||||
it('Code128 编码能够正确计算校验码并生成标准条空序列', () => {
|
||||
const { encodeCode128B, getBarcodeBits } = loadFrontendEsm(barcodeJsPath);
|
||||
const testCode = 'TICKET-101-8392';
|
||||
const segments = encodeCode128B(testCode);
|
||||
expect(Array.isArray(segments)).toBe(true);
|
||||
expect(segments.length).toBeGreaterThan(10);
|
||||
const bits = getBarcodeBits(testCode);
|
||||
expect(typeof bits).toBe('string');
|
||||
expect(bits).toMatch(/^[01]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. QR Code 二维码矩阵算法规范', () => {
|
||||
it('qrcode.js 文件必须存在且导出 generateQRCodeMatrix 和 QRCodeModel', () => {
|
||||
expect(fs.existsSync(qrcodeJsPath)).toBe(true);
|
||||
const content = fs.readFileSync(qrcodeJsPath, 'utf-8');
|
||||
expect(content).toContain('export function generateQRCodeMatrix');
|
||||
expect(content).toContain('export class QRCodeModel');
|
||||
});
|
||||
|
||||
it('QR Code 算法能够为任意门票串生成合法的 N*N 二维点阵矩阵', () => {
|
||||
const { generateQRCodeMatrix } = loadFrontendEsm(qrcodeJsPath);
|
||||
const testText = 'TICKET-101-8392';
|
||||
const matrix = generateQRCodeMatrix(testText, 2);
|
||||
expect(Array.isArray(matrix)).toBe(true);
|
||||
expect(matrix.length).toBe(25); // Ver 2: 25x25 矩阵
|
||||
expect(matrix[0].length).toBe(25);
|
||||
// 验证寻象图形 (Position Probe Pattern: 左上角第 0 行应有黑块)
|
||||
expect(matrix[0][0]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 门票页面真实接入门禁与移除Mock依赖', () => {
|
||||
it('my-ticket.vue 必须引入真实条形码与二维码生成算法', () => {
|
||||
const content = fs.readFileSync(ticketVuePath, 'utf-8');
|
||||
expect(content).toContain('encodeCode128B');
|
||||
expect(content).toContain('generateQRCodeMatrix');
|
||||
expect(content).toContain('barcodeSegments');
|
||||
expect(content).toContain('qrcodeMatrix');
|
||||
});
|
||||
|
||||
it('my-ticket.vue 必须彻底移除对 ACTIVITIES_LIST 假Mock数据的依赖', () => {
|
||||
const content = fs.readFileSync(ticketVuePath, 'utf-8');
|
||||
expect(content).not.toContain("import { ACTIVITIES_LIST } from '@/common/mock.js';");
|
||||
expect(content).not.toContain('const ticketData = ref(ACTIVITIES_LIST[0])');
|
||||
});
|
||||
|
||||
it('所有新写的代码中严禁包含任何系统 Emoji', () => {
|
||||
const bContent = fs.readFileSync(barcodeJsPath, 'utf-8');
|
||||
const qContent = fs.readFileSync(qrcodeJsPath, 'utf-8');
|
||||
const tContent = fs.readFileSync(ticketVuePath, 'utf-8');
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
expect(emojiRegex.test(bContent)).toBe(false);
|
||||
expect(emojiRegex.test(qContent)).toBe(false);
|
||||
expect(emojiRegex.test(tContent)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
712
server/tests/rbac-permission.spec.ts
普通文件
712
server/tests/rbac-permission.spec.ts
普通文件
@@ -0,0 +1,712 @@
|
||||
import { AuthRoleManager, UserRoleFlag, WorkMode, CertStatus } from '../src/core/auth/auth-role.manager';
|
||||
import { CompanionOrderService } from '../src/core/order/companion-order.service';
|
||||
import { ActivityService, ActivityPayType } from '../src/core/activity/activity.service';
|
||||
import { OrderStatus } from '../src/core/order/order-state-machine';
|
||||
import { CompanionTrack, ExpenseBurdenType } from '../src/core/companion/service-category';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('RBAC Permission Architecture (TDD 全流程多角色权限与越权防御套件)', () => {
|
||||
let roleManager: AuthRoleManager;
|
||||
let orderService: CompanionOrderService;
|
||||
let activityService: ActivityService;
|
||||
|
||||
beforeEach(() => {
|
||||
roleManager = new AuthRoleManager();
|
||||
orderService = new CompanionOrderService();
|
||||
activityService = new ActivityService();
|
||||
});
|
||||
|
||||
describe('1. 四大核心角色体系与权限位图验证', () => {
|
||||
it('角色位图常量定义完整且互斥', () => {
|
||||
expect(UserRoleFlag.PLAYER).toBe(1);
|
||||
expect(UserRoleFlag.EXPERT).toBe(2);
|
||||
expect(UserRoleFlag.HOST).toBe(4);
|
||||
expect(UserRoleFlag.ADMIN).toBe(8);
|
||||
});
|
||||
|
||||
it('普通注册用户默认仅拥有玩家身份 (PLAYER)', () => {
|
||||
const user = roleManager.registerUser({ openid: 'wx_user_1', nickname: '玩家小明' });
|
||||
expect(user.roleFlags).toBe(UserRoleFlag.PLAYER);
|
||||
expect(roleManager.isPlayer(user.id)).toBe(true);
|
||||
expect(roleManager.isExpert(user.id)).toBe(false);
|
||||
expect(roleManager.isHost(user.id)).toBe(false);
|
||||
expect(roleManager.isAdmin(user.id)).toBe(false);
|
||||
expect(user.currentMode).toBe(WorkMode.PLAYER);
|
||||
});
|
||||
|
||||
it('平台管理员账号具备 ADMIN 与 PLAYER 复合权限', () => {
|
||||
const admin = roleManager.registerAdmin({ openid: 'wx_admin_1', nickname: '总控管理员' });
|
||||
expect(roleManager.isAdmin(admin.id)).toBe(true);
|
||||
expect(roleManager.isPlayer(admin.id)).toBe(true);
|
||||
expect(admin.roleFlags & UserRoleFlag.ADMIN).toBeTruthy();
|
||||
});
|
||||
|
||||
it('支持动态授予与撤销角色位图权限', () => {
|
||||
const user = roleManager.registerUser({ openid: 'wx_user_2', nickname: '多面手' });
|
||||
expect(roleManager.isHost(user.id)).toBe(false);
|
||||
|
||||
// 授予车队房主 HOST 权限
|
||||
roleManager.grantRole(user.id, UserRoleFlag.HOST);
|
||||
expect(roleManager.isHost(user.id)).toBe(true);
|
||||
|
||||
// 撤销 HOST 权限
|
||||
roleManager.revokeRole(user.id, UserRoleFlag.HOST);
|
||||
expect(roleManager.isHost(user.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 角色一:普通买家/玩家 (PLAYER) 业务与越权防御', () => {
|
||||
let buyer: any;
|
||||
let expert: any;
|
||||
let stranger: any;
|
||||
|
||||
beforeEach(() => {
|
||||
buyer = roleManager.registerUser({ openid: 'wx_buyer', nickname: '下单老板' });
|
||||
expert = roleManager.registerUser({ openid: 'wx_expert', nickname: '认证大神' });
|
||||
stranger = roleManager.registerUser({ openid: 'wx_stranger', nickname: '路人用户' });
|
||||
|
||||
// 认证大神
|
||||
roleManager.submitExpertCert(expert.id, { realName: '李大神', idCardNo: '110101199001011234' });
|
||||
roleManager.approveExpertCert(expert.id);
|
||||
roleManager.switchMode(expert.id, WorkMode.EXPERT);
|
||||
});
|
||||
|
||||
it('买家正常创建并支付线上订单', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者荣耀五排上分',
|
||||
hours: 2,
|
||||
totalAmountCents: 6000,
|
||||
gameDetails: {
|
||||
gameName: '王者荣耀',
|
||||
gameId: '峡谷第一野王',
|
||||
serverDistrict: '微信区',
|
||||
},
|
||||
});
|
||||
|
||||
expect(order.status).toBe(OrderStatus.PENDING_PAYMENT);
|
||||
|
||||
// 只有买家有权付款
|
||||
orderService.payOrder(order.id, buyer.id);
|
||||
expect(order.status).toBe(OrderStatus.PENDING_ACCEPT);
|
||||
});
|
||||
|
||||
it('越权拦截:非下单买家尝试支付订单应被拒绝', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者双排',
|
||||
hours: 1,
|
||||
totalAmountCents: 3000,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
orderService.payOrder(order.id, stranger.id);
|
||||
}).toThrow('非下单买家无权支付该订单');
|
||||
});
|
||||
|
||||
it('待付款状态下买家可取消订单,非买家取消应被拒绝', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者双排',
|
||||
hours: 1,
|
||||
totalAmountCents: 3000,
|
||||
});
|
||||
|
||||
// 非买家尝试取消
|
||||
expect(() => {
|
||||
orderService.buyerCancel(order.id, stranger.id);
|
||||
}).toThrow('只有下单买家有权取消订单');
|
||||
|
||||
// 买家正常取消
|
||||
orderService.buyerCancel(order.id, buyer.id);
|
||||
expect(order.status).toBe(OrderStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('待确认状态下买家正常确认完成验收,非买家验收应被拒绝', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '排位服务',
|
||||
hours: 1,
|
||||
totalAmountCents: 3000,
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id, buyer.id);
|
||||
orderService.expertAccept(order.id, expert.id, roleManager);
|
||||
orderService.expertApplyFinish(order.id, expert.id);
|
||||
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM);
|
||||
|
||||
// 非买家尝试确认验收
|
||||
expect(() => {
|
||||
orderService.buyerConfirm(order.id, stranger.id);
|
||||
}).toThrow('只有下单买家有权确认完成验收');
|
||||
|
||||
// 买家确认完成
|
||||
orderService.buyerConfirm(order.id, buyer.id);
|
||||
expect(order.status).toBe(OrderStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('待确认状态下买家发起争议仲裁,非买家发起仲裁应被拒绝', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '排位服务',
|
||||
hours: 1,
|
||||
totalAmountCents: 3000,
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id, buyer.id);
|
||||
orderService.expertAccept(order.id, expert.id, roleManager);
|
||||
orderService.expertApplyFinish(order.id, expert.id);
|
||||
|
||||
// 非买家尝试发起争议
|
||||
expect(() => {
|
||||
orderService.buyerDispute(order.id, stranger.id, '大神挂机');
|
||||
}).toThrow('只有下单买家有权发起争议仲裁');
|
||||
|
||||
// 买家发起争议
|
||||
orderService.buyerDispute(order.id, buyer.id, '大神迟到并且对局中挂机');
|
||||
expect(order.status).toBe(OrderStatus.DISPUTED);
|
||||
expect(order.disputeReason).toBe('大神迟到并且对局中挂机');
|
||||
});
|
||||
|
||||
it('越权拦截:买家不可越权执行平台管理员争议仲裁', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '排位服务',
|
||||
hours: 1,
|
||||
totalAmountCents: 3000,
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id, buyer.id);
|
||||
orderService.expertAccept(order.id, expert.id, roleManager);
|
||||
orderService.expertApplyFinish(order.id, expert.id);
|
||||
orderService.buyerDispute(order.id, buyer.id, '要求全额退款');
|
||||
|
||||
// 买家尝试越权自行仲裁退款
|
||||
expect(() => {
|
||||
orderService.arbitrate({
|
||||
orderId: order.id,
|
||||
operatorAdminId: buyer.id,
|
||||
decision: 'REFUND',
|
||||
roleManager,
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 角色二:陪玩大神/陪伴师 (EXPERT) 认证全流程与越权防御', () => {
|
||||
let applicant: any;
|
||||
let normalBuyer: any;
|
||||
let admin: any;
|
||||
|
||||
beforeEach(() => {
|
||||
applicant = roleManager.registerUser({ openid: 'wx_app_1', nickname: '申请大神选手' });
|
||||
normalBuyer = roleManager.registerUser({ openid: 'wx_buy_2', nickname: '买家阿强' });
|
||||
admin = roleManager.registerAdmin({ openid: 'wx_admin_2', nickname: '平台运营审核员' });
|
||||
});
|
||||
|
||||
it('申请实名认证成功并生成 PENDING 记录', () => {
|
||||
const cert = roleManager.submitExpertCert(applicant.id, {
|
||||
realName: '王小萌',
|
||||
idCardNo: '110101200001011111',
|
||||
});
|
||||
expect(cert.status).toBe(CertStatus.PENDING);
|
||||
expect(roleManager.getCertStatus(applicant.id)).toBe(CertStatus.PENDING);
|
||||
expect(roleManager.isExpert(applicant.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('越权拦截:普通买家尝试审核大神认证申请应被抛出权限异常拒绝', () => {
|
||||
roleManager.submitExpertCert(applicant.id, {
|
||||
realName: '王小萌',
|
||||
idCardNo: '110101200001011111',
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
roleManager.approveExpertCert(applicant.id, normalBuyer.id);
|
||||
}).toThrow('只有平台管理员有权审核大神认证申请');
|
||||
|
||||
expect(() => {
|
||||
roleManager.rejectExpertCert(applicant.id, '资料不合格', normalBuyer.id);
|
||||
}).toThrow('只有平台管理员有权审核大神认证申请');
|
||||
});
|
||||
|
||||
it('平台管理员成功审核通过实名认证,角色位图升级并支持接单', () => {
|
||||
roleManager.submitExpertCert(applicant.id, {
|
||||
realName: '王小萌',
|
||||
idCardNo: '110101200001011111',
|
||||
});
|
||||
|
||||
const cert = roleManager.approveExpertCert(applicant.id, admin.id);
|
||||
expect(cert.status).toBe(CertStatus.APPROVED);
|
||||
expect(cert.auditedBy).toBe(admin.id);
|
||||
expect(roleManager.isExpert(applicant.id)).toBe(true);
|
||||
|
||||
// 允许切换到接单模式
|
||||
roleManager.switchMode(applicant.id, WorkMode.EXPERT);
|
||||
expect(roleManager.getUser(applicant.id).currentMode).toBe(WorkMode.EXPERT);
|
||||
});
|
||||
|
||||
it('平台管理员驳回实名认证并记录驳回理由', () => {
|
||||
roleManager.submitExpertCert(applicant.id, {
|
||||
realName: '王小萌',
|
||||
idCardNo: '110101200001011111',
|
||||
});
|
||||
|
||||
const cert = roleManager.rejectExpertCert(applicant.id, '身份证照片模糊不清晰', admin.id);
|
||||
expect(cert.status).toBe(CertStatus.REJECTED);
|
||||
expect(cert.rejectReason).toBe('身份证照片模糊不清晰');
|
||||
expect(roleManager.isExpert(applicant.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('越权拦截:未认证用户尝试接单应被严格拦截拒绝', () => {
|
||||
const order = orderService.createOrder({
|
||||
buyerId: normalBuyer.id,
|
||||
expertId: applicant.id, // 尚未认证
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '游戏陪玩',
|
||||
hours: 1,
|
||||
totalAmountCents: 3500,
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id, normalBuyer.id);
|
||||
|
||||
expect(() => {
|
||||
orderService.expertAccept(order.id, applicant.id, roleManager);
|
||||
}).toThrow('未认证大神无法接单');
|
||||
});
|
||||
|
||||
it('同城线下订单生成 4 位现场核销码,非接单大神核销被拒绝,接单大神现场核销闭环', () => {
|
||||
// 认证大神
|
||||
roleManager.submitExpertCert(applicant.id, { realName: '王小萌', idCardNo: '110101200001011111' });
|
||||
roleManager.approveExpertCert(applicant.id, admin.id);
|
||||
|
||||
const order = orderService.createOrder({
|
||||
buyerId: normalBuyer.id,
|
||||
expertId: applicant.id,
|
||||
track: CompanionTrack.OFFLINE_MEAL,
|
||||
serviceTitle: '三里屯火锅探店',
|
||||
hours: 2,
|
||||
totalAmountCents: 12000,
|
||||
offlineDetails: {
|
||||
city: '北京市',
|
||||
venueName: '朝阳大悦城海底捞',
|
||||
meetTime: '今晚 19:30',
|
||||
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
||||
},
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id, normalBuyer.id);
|
||||
orderService.expertAccept(order.id, applicant.id, roleManager);
|
||||
|
||||
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM);
|
||||
expect(order.offlineVerifyCode).toMatch(/^\d{4}$/);
|
||||
|
||||
// 非接单大神越权核销
|
||||
expect(() => {
|
||||
orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: 9999, // 外部非法大神
|
||||
verifyCode: order.offlineVerifyCode!,
|
||||
});
|
||||
}).toThrow('无权核销该订单');
|
||||
|
||||
// 接单大神凭正确 4 位码核销成功进入服务中
|
||||
const checkinRes = orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: applicant.id,
|
||||
verifyCode: order.offlineVerifyCode!,
|
||||
});
|
||||
expect(checkinRes.success).toBe(true);
|
||||
expect(order.status).toBe(OrderStatus.IN_SERVICE);
|
||||
|
||||
// 防重校验:不可重复核销
|
||||
expect(() => {
|
||||
orderService.verifyOfflineCheckin({
|
||||
orderId: order.id,
|
||||
operatorExpertId: applicant.id,
|
||||
verifyCode: order.offlineVerifyCode!,
|
||||
});
|
||||
}).toThrow('该订单已完成现场核销签到');
|
||||
});
|
||||
|
||||
it('接单大神拒单流转为已退款,非指定接单大神拒单应被拦截', () => {
|
||||
roleManager.submitExpertCert(applicant.id, { realName: '王小萌', idCardNo: '110101200001011111' });
|
||||
roleManager.approveExpertCert(applicant.id, admin.id);
|
||||
|
||||
const order = orderService.createOrder({
|
||||
buyerId: normalBuyer.id,
|
||||
expertId: applicant.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '游戏陪练',
|
||||
hours: 1,
|
||||
totalAmountCents: 3500,
|
||||
});
|
||||
orderService.payOrder(order.id, normalBuyer.id);
|
||||
|
||||
// 非指定接单大神尝试拒单
|
||||
expect(() => {
|
||||
orderService.expertReject(order.id, 9999);
|
||||
}).toThrow('只有指定接单大神有权拒单');
|
||||
|
||||
// 指定接单大神拒单
|
||||
orderService.expertReject(order.id, applicant.id, '时间冲突');
|
||||
expect(order.status).toBe(OrderStatus.REFUNDED);
|
||||
});
|
||||
|
||||
it('越权拦截:大神不可越权执行平台管理员争议仲裁', () => {
|
||||
roleManager.submitExpertCert(applicant.id, { realName: '王小萌', idCardNo: '110101200001011111' });
|
||||
roleManager.approveExpertCert(applicant.id, admin.id);
|
||||
|
||||
const order = orderService.createOrder({
|
||||
buyerId: normalBuyer.id,
|
||||
expertId: applicant.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '游戏陪练',
|
||||
hours: 1,
|
||||
totalAmountCents: 3500,
|
||||
});
|
||||
orderService.payOrder(order.id, normalBuyer.id);
|
||||
orderService.expertAccept(order.id, applicant.id, roleManager);
|
||||
orderService.expertApplyFinish(order.id, applicant.id);
|
||||
orderService.buyerDispute(order.id, normalBuyer.id, '未达段位');
|
||||
|
||||
// 大神尝试越权自行结单打款
|
||||
expect(() => {
|
||||
orderService.arbitrate({
|
||||
orderId: order.id,
|
||||
operatorAdminId: applicant.id,
|
||||
decision: 'RELEASE',
|
||||
roleManager,
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 角色三:车队房主/局长 (HOST) 权限闭环与越权防御', () => {
|
||||
let host: any;
|
||||
let participant1: any;
|
||||
let participant2: any;
|
||||
let outsider: any;
|
||||
let admin: any;
|
||||
|
||||
beforeEach(() => {
|
||||
host = roleManager.registerUser({ openid: 'wx_host', nickname: '局长房主' });
|
||||
participant1 = roleManager.registerUser({ openid: 'wx_p1', nickname: '搭子甲' });
|
||||
participant2 = roleManager.registerUser({ openid: 'wx_p2', nickname: '搭子乙' });
|
||||
outsider = roleManager.registerUser({ openid: 'wx_out', nickname: '无关人员' });
|
||||
admin = roleManager.registerAdmin({ openid: 'wx_admin_3', nickname: '监管运营' });
|
||||
});
|
||||
|
||||
it('房主发起组局自动获得 HOST 权限', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: host.id,
|
||||
title: '【周末同城】火锅探店4人局',
|
||||
gameCategory: '同城陪吃',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 5000,
|
||||
maxParticipants: 4,
|
||||
roleManager,
|
||||
});
|
||||
|
||||
expect(act.creatorId).toBe(host.id);
|
||||
expect(roleManager.isHost(host.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('房主动态调价加赏差额全员补齐,非房主调价抛出权限异常', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: host.id,
|
||||
title: '【加赏急招】电竞开黑车队',
|
||||
gameCategory: '热门电竞',
|
||||
payType: ActivityPayType.PROJECT_PAY,
|
||||
perPersonFeeCents: 5000,
|
||||
maxParticipants: 3,
|
||||
roleManager,
|
||||
});
|
||||
|
||||
const p1 = activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 5000 });
|
||||
|
||||
// 非房主尝试调价
|
||||
expect(() => {
|
||||
activityService.adjustActivityPrice({
|
||||
activityId: act.id,
|
||||
operatorId: outsider.id,
|
||||
newPriceCents: 8000,
|
||||
});
|
||||
}).toThrow('只有发起人有权调整活动出价');
|
||||
|
||||
// 房主成功调价至80元/人
|
||||
const res = activityService.adjustActivityPrice({
|
||||
activityId: act.id,
|
||||
operatorId: host.id,
|
||||
newPriceCents: 8000,
|
||||
});
|
||||
|
||||
expect(res.priceDiffCents).toBe(3000);
|
||||
expect(p1.payAmountCents).toBe(8000);
|
||||
});
|
||||
|
||||
it('房主使用 4 位核销码核销到场搭子,非房主核销被拦截拒绝', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: host.id,
|
||||
title: '同城剧本杀局',
|
||||
gameCategory: '同城陪玩',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 6000,
|
||||
maxParticipants: 4,
|
||||
roleManager,
|
||||
});
|
||||
|
||||
const p = activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 6000 });
|
||||
|
||||
// 非房主越权核销搭子门票
|
||||
expect(() => {
|
||||
activityService.verifyCheckin({
|
||||
activityId: act.id,
|
||||
operatorId: outsider.id,
|
||||
verifyCode: p.verifyCode,
|
||||
});
|
||||
}).toThrow('无权核销该活动的核销码');
|
||||
|
||||
// 房主核销成功
|
||||
const checkRes = activityService.verifyCheckin({
|
||||
activityId: act.id,
|
||||
operatorId: host.id,
|
||||
verifyCode: p.verifyCode,
|
||||
});
|
||||
expect(checkRes.success).toBe(true);
|
||||
expect(checkRes.participant.checkinStatus).toBe(true);
|
||||
});
|
||||
|
||||
it('房主解散组局并全额原路退款已报名搭子,非房主解散被拦截', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: host.id,
|
||||
title: '同城KTV狂欢局',
|
||||
gameCategory: '同城陪唱',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 7500,
|
||||
maxParticipants: 4,
|
||||
roleManager,
|
||||
});
|
||||
|
||||
activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 7500 });
|
||||
activityService.joinActivity(act.id, { userId: participant2.id, payAmountCents: 7500 });
|
||||
|
||||
// 非房主尝试解散
|
||||
expect(() => {
|
||||
activityService.cancelActivity({
|
||||
activityId: act.id,
|
||||
operatorId: outsider.id,
|
||||
reason: '恶意解散',
|
||||
});
|
||||
}).toThrow('只有发起人有权解散此组局');
|
||||
|
||||
// 房主正常解散
|
||||
const cancelRes = activityService.cancelActivity({
|
||||
activityId: act.id,
|
||||
operatorId: host.id,
|
||||
reason: '突发暴雨改期',
|
||||
});
|
||||
expect(cancelRes.success).toBe(true);
|
||||
expect(cancelRes.status).toBe('CANCELLED');
|
||||
expect(cancelRes.refundedCount).toBe(2);
|
||||
expect(cancelRes.totalRefundCents).toBe(15000);
|
||||
});
|
||||
|
||||
it('平台管理员具备全平台活动监管强制解散权限', () => {
|
||||
const act = activityService.createActivity({
|
||||
creatorId: host.id,
|
||||
title: '涉嫌违规商圈组局',
|
||||
gameCategory: '同城陪喝',
|
||||
payType: ActivityPayType.AA_PAY,
|
||||
aaFeeCents: 5000,
|
||||
maxParticipants: 2,
|
||||
roleManager,
|
||||
});
|
||||
|
||||
activityService.joinActivity(act.id, { userId: participant1.id, payAmountCents: 5000 });
|
||||
|
||||
// 非管理员调用强制解散被拒绝
|
||||
expect(() => {
|
||||
activityService.adminForceCancelActivity({
|
||||
activityId: act.id,
|
||||
operatorAdminId: outsider.id,
|
||||
reason: '违规封禁',
|
||||
roleManager,
|
||||
});
|
||||
}).toThrow('只有平台管理员有权执行平台强制解散');
|
||||
|
||||
// 平台管理员执行强制解散并全额退款
|
||||
const adminCancelRes = activityService.adminForceCancelActivity({
|
||||
activityId: act.id,
|
||||
operatorAdminId: admin.id,
|
||||
reason: '违规场所管控下架',
|
||||
roleManager,
|
||||
});
|
||||
expect(adminCancelRes.success).toBe(true);
|
||||
expect(adminCancelRes.refundedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 角色四:平台管理员/运营端 (ADMIN) 仲裁与全流程监管', () => {
|
||||
let buyer: any;
|
||||
let expert: any;
|
||||
let admin: any;
|
||||
let order: any;
|
||||
|
||||
beforeEach(() => {
|
||||
buyer = roleManager.registerUser({ openid: 'wx_b_arb', nickname: '申诉买家' });
|
||||
expert = roleManager.registerUser({ openid: 'wx_e_arb', nickname: '涉事大神' });
|
||||
admin = roleManager.registerAdmin({ openid: 'wx_a_arb', nickname: '总台仲裁法官' });
|
||||
|
||||
roleManager.submitExpertCert(expert.id, { realName: '赵选手', idCardNo: '110101199501018888' });
|
||||
roleManager.approveExpertCert(expert.id, admin.id);
|
||||
|
||||
order = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '王者双排冲国服',
|
||||
hours: 3,
|
||||
totalAmountCents: 10500,
|
||||
});
|
||||
|
||||
orderService.payOrder(order.id, buyer.id);
|
||||
orderService.expertAccept(order.id, expert.id, roleManager);
|
||||
orderService.expertApplyFinish(order.id, expert.id);
|
||||
orderService.buyerDispute(order.id, buyer.id, '大神接单后擅自挂机且言语态度恶劣,申请全额退款');
|
||||
});
|
||||
|
||||
it('管理员判定买家胜诉全额退款 (REFUND),订单流转为 REFUNDED', () => {
|
||||
expect(order.status).toBe(OrderStatus.DISPUTED);
|
||||
|
||||
const arbRes = orderService.arbitrate({
|
||||
orderId: order.id,
|
||||
operatorAdminId: admin.id,
|
||||
decision: 'REFUND',
|
||||
reason: '查证大神确实严重违规挂机,判定全额退款买家并记录信用扣分',
|
||||
roleManager,
|
||||
});
|
||||
|
||||
expect(arbRes.status).toBe(OrderStatus.REFUNDED);
|
||||
expect(arbRes.arbitrationDecision).toBe('REFUND');
|
||||
expect(arbRes.arbitratedBy).toBe(admin.id);
|
||||
expect(arbRes.arbitratedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('管理员判定申诉不成立驳回 (RELEASE),订单流转为 COMPLETED', () => {
|
||||
expect(order.status).toBe(OrderStatus.DISPUTED);
|
||||
|
||||
const arbRes = orderService.arbitrate({
|
||||
orderId: order.id,
|
||||
operatorAdminId: admin.id,
|
||||
decision: 'RELEASE',
|
||||
reason: '核实对局战绩完整履约且达成胜率指标,驳回申诉并释放服务报酬',
|
||||
roleManager,
|
||||
});
|
||||
|
||||
expect(arbRes.status).toBe(OrderStatus.COMPLETED);
|
||||
expect(arbRes.arbitrationDecision).toBe('RELEASE');
|
||||
expect(arbRes.arbitratedBy).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('越权拦截:非平台管理员尝试执行仲裁判定被抛出异常拒绝', () => {
|
||||
const impostor = roleManager.registerUser({ openid: 'wx_impostor', nickname: '假冒管理员' });
|
||||
|
||||
expect(() => {
|
||||
orderService.arbitrate({
|
||||
orderId: order.id,
|
||||
operatorAdminId: impostor.id,
|
||||
decision: 'REFUND',
|
||||
roleManager,
|
||||
});
|
||||
}).toThrow('只有平台管理员有权执行争议仲裁');
|
||||
});
|
||||
|
||||
it('非 DISPUTED 状态的订单不可执行仲裁判定', () => {
|
||||
const normalOrder = orderService.createOrder({
|
||||
buyerId: buyer.id,
|
||||
expertId: expert.id,
|
||||
track: CompanionTrack.ONLINE_GAME,
|
||||
serviceTitle: '普通对局',
|
||||
hours: 1,
|
||||
totalAmountCents: 3500,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
orderService.arbitrate({
|
||||
orderId: normalOrder.id,
|
||||
operatorAdminId: admin.id,
|
||||
decision: 'REFUND',
|
||||
roleManager,
|
||||
});
|
||||
}).toThrow('非争议仲裁中,不可执行仲裁');
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. 前端 UniApp 双身份与角色守卫 (Role Guard) 规则门禁验证', () => {
|
||||
const roleJsPath = path.resolve(__dirname, '../../pwuniapp/common/role.js');
|
||||
const roleJsContent = fs.readFileSync(roleJsPath, 'utf-8');
|
||||
|
||||
it('role.js 必须定义四大核心角色 (PLAYER, EXPERT, HOST, ADMIN)', () => {
|
||||
expect(roleJsContent).toContain("PLAYER: 'PLAYER'");
|
||||
expect(roleJsContent).toContain("EXPERT: 'EXPERT'");
|
||||
expect(roleJsContent).toContain("HOST: 'HOST'");
|
||||
expect(roleJsContent).toContain("ADMIN: 'ADMIN'");
|
||||
});
|
||||
|
||||
it('role.js 必须包含完整的认证状态机定义 (CERT_STATUS)', () => {
|
||||
expect(roleJsContent).toContain('NONE: 0');
|
||||
expect(roleJsContent).toContain('PENDING: 1');
|
||||
expect(roleJsContent).toContain('APPROVED: 2');
|
||||
expect(roleJsContent).toContain('REJECTED: 3');
|
||||
});
|
||||
|
||||
it('role.js 必须配置非大神禁止访问的受保护页面清单与 checkRoleGuard 守卫拦截', () => {
|
||||
expect(roleJsContent).toContain("'/pages/expert/workspace'");
|
||||
expect(roleJsContent).toContain("'/pages/expert/radar'");
|
||||
expect(roleJsContent).toContain("'/pages/expert/hall'");
|
||||
expect(roleJsContent).toContain('function checkRoleGuard');
|
||||
expect(roleJsContent).toContain('function isExpertCertified');
|
||||
expect(roleJsContent).toContain('function installRoleGuard');
|
||||
});
|
||||
|
||||
it('App.vue 必须在 onLaunch 中注册 installRoleGuard 全局路由守卫', () => {
|
||||
const appVuePath = path.resolve(__dirname, '../../pwuniapp/App.vue');
|
||||
const appVueContent = fs.readFileSync(appVuePath, 'utf-8');
|
||||
expect(appVueContent).toContain('installRoleGuard');
|
||||
expect(appVueContent).toContain('installRoleGuard()');
|
||||
});
|
||||
|
||||
it('CustomTabBar 底部导航栏必须对未认证非大神访问接单端进行权限拦截', () => {
|
||||
const tabbarPath = path.resolve(__dirname, '../../pwuniapp/components/CustomTabBar.vue');
|
||||
const tabbarContent = fs.readFileSync(tabbarPath, 'utf-8');
|
||||
expect(tabbarContent).toContain('isExpertCertified');
|
||||
});
|
||||
|
||||
it('三大专家页面必须在 onShow 生命周期中配置兜底权限重定向防穿透', () => {
|
||||
const expertPages = [
|
||||
path.resolve(__dirname, '../../pwuniapp/pages/expert/workspace.vue'),
|
||||
path.resolve(__dirname, '../../pwuniapp/pages/expert/radar.vue'),
|
||||
path.resolve(__dirname, '../../pwuniapp/pages/expert/hall.vue'),
|
||||
];
|
||||
|
||||
for (const p of expertPages) {
|
||||
const content = fs.readFileSync(p, 'utf-8');
|
||||
expect(content).toContain('isExpertCertified');
|
||||
expect(content).toContain('/pages/user/index');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
158
server/tests/review-comment.spec.ts
普通文件
158
server/tests/review-comment.spec.ts
普通文件
@@ -0,0 +1,158 @@
|
||||
import { ReviewAndCommentService } from '../src/core/review/review.service';
|
||||
import { OrderStatus } from '../src/core/order/order-state-machine';
|
||||
|
||||
describe('ReviewAndCommentService (TDD 订单评价打分与动态评论测试套件)', () => {
|
||||
let service: ReviewAndCommentService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ReviewAndCommentService();
|
||||
});
|
||||
|
||||
describe('1. 订单评价业务规则与越权拦截', () => {
|
||||
it('已完成(COMPLETED)状态的订单成功提交 5 星好评与评价标签', () => {
|
||||
const review = service.submitOrderReview({
|
||||
orderId: 'ord_1001',
|
||||
orderStatus: OrderStatus.COMPLETED,
|
||||
expertId: 101,
|
||||
userId: 2001,
|
||||
userNickname: '电竞小王子',
|
||||
rating: 5,
|
||||
tags: ['技术强', '声音好听', '带飞吃鸡'],
|
||||
content: '大神非常给力,连续吃鸡3把!'
|
||||
});
|
||||
|
||||
expect(review.id).toBeDefined();
|
||||
expect(review.rating).toBe(5);
|
||||
expect(review.tags).toContain('带飞吃鸡');
|
||||
expect(review.orderId).toBe('ord_1001');
|
||||
});
|
||||
|
||||
it('非 COMPLETED 状态的订单(如服务中/待付款)尝试评价应被拒绝拦截', () => {
|
||||
expect(() => {
|
||||
service.submitOrderReview({
|
||||
orderId: 'ord_1002',
|
||||
orderStatus: OrderStatus.IN_SERVICE,
|
||||
expertId: 101,
|
||||
userId: 2001,
|
||||
userNickname: '小李',
|
||||
rating: 5,
|
||||
});
|
||||
}).toThrow('只有已完成(COMPLETED)状态的订单才允许提交服务评价');
|
||||
});
|
||||
|
||||
it('评分小于 1 或大于 5 时应抛出参数校验异常', () => {
|
||||
expect(() => {
|
||||
service.submitOrderReview({
|
||||
orderId: 'ord_1003',
|
||||
orderStatus: OrderStatus.COMPLETED,
|
||||
expertId: 101,
|
||||
userId: 2001,
|
||||
userNickname: '小李',
|
||||
rating: 6,
|
||||
});
|
||||
}).toThrow('评分必须在 1 至 5 星之间');
|
||||
|
||||
expect(() => {
|
||||
service.submitOrderReview({
|
||||
orderId: 'ord_1004',
|
||||
orderStatus: OrderStatus.COMPLETED,
|
||||
expertId: 101,
|
||||
userId: 2001,
|
||||
userNickname: '小李',
|
||||
rating: 0,
|
||||
});
|
||||
}).toThrow('评分必须在 1 至 5 星之间');
|
||||
});
|
||||
|
||||
it('同一订单禁止重复提交评价(防刷评)', () => {
|
||||
service.submitOrderReview({
|
||||
orderId: 'ord_1005',
|
||||
orderStatus: OrderStatus.COMPLETED,
|
||||
expertId: 101,
|
||||
userId: 2001,
|
||||
userNickname: '小李',
|
||||
rating: 5,
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
service.submitOrderReview({
|
||||
orderId: 'ord_1005',
|
||||
orderStatus: OrderStatus.COMPLETED,
|
||||
expertId: 101,
|
||||
userId: 2001,
|
||||
userNickname: '小李',
|
||||
rating: 4,
|
||||
});
|
||||
}).toThrow('该订单已评价,不可重复提交评价');
|
||||
});
|
||||
|
||||
it('大神新增多条评价后,动态综合平均评分计算准确', () => {
|
||||
service.submitOrderReview({
|
||||
orderId: 'ord_201',
|
||||
orderStatus: OrderStatus.COMPLETED,
|
||||
expertId: 102,
|
||||
userId: 301,
|
||||
userNickname: '玩家A',
|
||||
rating: 5,
|
||||
});
|
||||
|
||||
service.submitOrderReview({
|
||||
orderId: 'ord_202',
|
||||
orderStatus: OrderStatus.COMPLETED,
|
||||
expertId: 102,
|
||||
userId: 302,
|
||||
userNickname: '玩家B',
|
||||
rating: 4,
|
||||
});
|
||||
|
||||
const avg = service.calculateExpertAverageRating(102);
|
||||
expect(avg).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 友圈动态互动评论区规则', () => {
|
||||
it('成功发表动态互动评论并支持按动态聚合查询', () => {
|
||||
const c1 = service.submitPostComment({
|
||||
postId: 'post_001',
|
||||
userId: 2001,
|
||||
author: '三里屯吃货',
|
||||
content: '我也在三里屯,求组队!'
|
||||
});
|
||||
|
||||
const c2 = service.submitPostComment({
|
||||
postId: 'post_001',
|
||||
userId: 2002,
|
||||
author: '火锅达人',
|
||||
content: '晚上几点开饭?已加微。'
|
||||
});
|
||||
|
||||
const list = service.getCommentsByPost('post_001');
|
||||
expect(list.length).toBe(2);
|
||||
expect(list[0].id).toBe(c1.id);
|
||||
expect(list[1].id).toBe(c2.id);
|
||||
});
|
||||
|
||||
it('空评论或纯空格评论应被拦截', () => {
|
||||
expect(() => {
|
||||
service.submitPostComment({
|
||||
postId: 'post_001',
|
||||
userId: 2001,
|
||||
author: '张三',
|
||||
content: ' '
|
||||
});
|
||||
}).toThrow('评论内容不能为空');
|
||||
});
|
||||
|
||||
it('超长评论(超过500字)应被安全阻断', () => {
|
||||
const longText = 'a'.repeat(501);
|
||||
expect(() => {
|
||||
service.submitPostComment({
|
||||
postId: 'post_001',
|
||||
userId: 2001,
|
||||
author: '张三',
|
||||
content: longText
|
||||
});
|
||||
}).toThrow('评论内容不能超过 500 个字符');
|
||||
});
|
||||
});
|
||||
});
|
||||
83
server/tests/skeleton.spec.ts
普通文件
83
server/tests/skeleton.spec.ts
普通文件
@@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('SkeletonScreen Architecture (TDD 骨架屏流光扫描与首屏加载体验门禁)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const skeletonComponentPath = path.join(rootDir, 'pwuniapp/components/SkeletonScreen.vue');
|
||||
|
||||
const pagePaths = {
|
||||
index: path.join(rootDir, 'pwuniapp/pages/index/index.vue'),
|
||||
activity: path.join(rootDir, 'pwuniapp/pages/activity/list.vue'),
|
||||
community: path.join(rootDir, 'pwuniapp/pages/community/index.vue'),
|
||||
expert: path.join(rootDir, 'pwuniapp/pages/expert/detail.vue')
|
||||
};
|
||||
|
||||
describe('1. 骨架屏公共组件规范性验证 (SkeletonScreen.vue)', () => {
|
||||
it('SkeletonScreen.vue 文件必须存在且格式有效', () => {
|
||||
expect(fs.existsSync(skeletonComponentPath)).toBe(true);
|
||||
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
|
||||
expect(content.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('必须支持四大核心业务形态:home、activity、community、expert', () => {
|
||||
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
|
||||
expect(content).toContain("type === 'home'");
|
||||
expect(content).toContain("type === 'activity'");
|
||||
expect(content).toContain("type === 'community'");
|
||||
expect(content).toContain("type === 'expert'");
|
||||
});
|
||||
|
||||
it('必须包含纯 CSS 骨架扫光动画与流光渐变关键帧', () => {
|
||||
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
|
||||
expect(content).toContain('skeletonShimmer');
|
||||
expect(content).toContain('@keyframes skeletonShimmer');
|
||||
expect(content).toContain('skeleton-shimmer');
|
||||
});
|
||||
|
||||
it('必须完整支持双主题系统 (暗夜冷光与 theme-warm 浅色暖光)', () => {
|
||||
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
|
||||
expect(content).toContain('themeClass');
|
||||
expect(content).toContain('.theme-warm');
|
||||
});
|
||||
|
||||
it('骨架屏组件必须严格遵循零 Emoji 门禁规范', () => {
|
||||
const content = fs.readFileSync(skeletonComponentPath, 'utf-8');
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
expect(emojiRegex.test(content)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 四大核心业务页面骨架屏接入门禁', () => {
|
||||
it('首页 (index.vue) 必须引入 SkeletonScreen 并配置 home 骨架屏与 isLoading 状态机', () => {
|
||||
const content = fs.readFileSync(pagePaths.index, 'utf-8');
|
||||
expect(content).toContain('SkeletonScreen');
|
||||
expect(content).toContain('<skeleton-screen');
|
||||
expect(content).toContain('type="home"');
|
||||
expect(content).toContain('isLoading');
|
||||
});
|
||||
|
||||
it('活动大厅 (activity/list.vue) 必须引入 SkeletonScreen 并配置 activity 骨架屏与 isLoading 状态机', () => {
|
||||
const content = fs.readFileSync(pagePaths.activity, 'utf-8');
|
||||
expect(content).toContain('SkeletonScreen');
|
||||
expect(content).toContain('<skeleton-screen');
|
||||
expect(content).toContain('type="activity"');
|
||||
expect(content).toContain('isLoading');
|
||||
});
|
||||
|
||||
it('友圈广场 (community/index.vue) 必须引入 SkeletonScreen 并配置 community 骨架屏与 isLoading 状态机', () => {
|
||||
const content = fs.readFileSync(pagePaths.community, 'utf-8');
|
||||
expect(content).toContain('SkeletonScreen');
|
||||
expect(content).toContain('<skeleton-screen');
|
||||
expect(content).toContain('type="community"');
|
||||
expect(content).toContain('isLoading');
|
||||
});
|
||||
|
||||
it('大神主页 (expert/detail.vue) 必须引入 SkeletonScreen 并配置 expert 骨架屏与 isLoading 状态机', () => {
|
||||
const content = fs.readFileSync(pagePaths.expert, 'utf-8');
|
||||
expect(content).toContain('SkeletonScreen');
|
||||
expect(content).toContain('<skeleton-screen');
|
||||
expect(content).toContain('type="expert"');
|
||||
expect(content).toContain('isLoading');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -393,6 +393,42 @@ describe('ThemeManager (TDD 测试套件: 暖色系与双主题设计系统)', (
|
||||
expect(content.includes('capsule-space') || content.includes('MP-WEIXIN')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('11. 底部自定义导航栏 (CustomTabBar) 与多端安全区门禁校验', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const customTabBarPath = path.join(rootDir, 'pwuniapp', 'components', 'CustomTabBar.vue');
|
||||
|
||||
it('CustomTabBar 容器高度必须动态叠加 safe-area-inset-bottom,防止全面屏黑条遮挡截断', () => {
|
||||
const content = fs.readFileSync(customTabBarPath, 'utf-8');
|
||||
expect(content).toContain('env(safe-area-inset-bottom)');
|
||||
expect(content).toMatch(/height:\s*calc\(118rpx\s*\+\s*env\(safe-area-inset-bottom\)\)/);
|
||||
expect(content).toContain('height: 118rpx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('12. 顶部胶囊与灵动岛状态栏像素级对齐门禁校验', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const navJsPath = path.join(rootDir, 'pwuniapp', 'common', 'nav.js');
|
||||
const indexVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'index', 'index.vue');
|
||||
const activityVuePath = path.join(rootDir, 'pwuniapp', 'pages', 'activity', 'list.vue');
|
||||
|
||||
it('nav.js 必须具备动态解析状态栏与微信胶囊位置的能力', () => {
|
||||
expect(fs.existsSync(navJsPath)).toBe(true);
|
||||
const content = fs.readFileSync(navJsPath, 'utf-8');
|
||||
expect(content).toContain('getMenuButtonBoundingClientRect');
|
||||
expect(content).toContain('statusBarHeight');
|
||||
expect(content).toContain('capsuleRightSpace');
|
||||
});
|
||||
|
||||
it('核心搜索页面必须动态绑定 navStyle 并在小程序端对齐胶囊高度', () => {
|
||||
const indexContent = fs.readFileSync(indexVuePath, 'utf-8');
|
||||
const actContent = fs.readFileSync(activityVuePath, 'utf-8');
|
||||
expect(indexContent).toContain('getNavBarInfo');
|
||||
expect(indexContent).toContain(':style="navStyle"');
|
||||
expect(actContent).toContain('getNavBarInfo');
|
||||
expect(actContent).toContain(':style="navStyle"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
33
server/tests/upload.spec.ts
普通文件
33
server/tests/upload.spec.ts
普通文件
@@ -0,0 +1,33 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('FileUpload Service (TDD 文件上传与静态对象存储服务门禁)', () => {
|
||||
const rootDir = path.resolve(__dirname, '../..');
|
||||
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||
const uploadsDirPath = path.join(rootDir, 'server/public/uploads');
|
||||
|
||||
it('静态文件上传持久化目录 public/uploads 必须存在', () => {
|
||||
expect(fs.existsSync(uploadsDirPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('server/src/index.ts 必须挂载 /uploads 静态托管路由与 /api/upload 接口', () => {
|
||||
const content = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(content).toContain("app.use('/uploads', express.static(uploadDir));");
|
||||
expect(content).toContain("app.post('/api/upload', handleFileUpload);");
|
||||
expect(content).toContain("app.post('/api/user/upload', handleFileUpload);");
|
||||
});
|
||||
|
||||
it('文件上传配置必须限制 10MB 与合法图片格式过滤', () => {
|
||||
const content = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
expect(content).toContain('10 * 1024 * 1024');
|
||||
expect(content).toContain('image/jpeg');
|
||||
expect(content).toContain('image/png');
|
||||
expect(content).toContain('image/webp');
|
||||
});
|
||||
|
||||
it('文件上传逻辑代码中必须严格遵循零 Emoji 门禁规范', () => {
|
||||
const content = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
expect(emojiRegex.test(content)).toBe(false);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户