文件
shanghaipeiwan/server/tests/activity.spec.ts

397 行
14 KiB
TypeScript

import { ActivityService, ActivityPayType, Activity, Participant } from '../src/core/activity/activity.service';
describe('ActivityService (TDD 测试套件: 活动项目与4位核销码)', () => {
let activityService: ActivityService;
beforeEach(() => {
activityService = new ActivityService();
});
describe('1. 发起活动 (AA制与项目制)', () => {
it('应成功创建AA制活动并正确初始化', () => {
const activity = activityService.createActivity({
creatorId: 1001,
title: '王者荣耀荣耀王者五排冲星车队',
gameCategory: '王者荣耀',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 3000, // 每人30元
maxParticipants: 4, // 限制4人,0表示不限制
});
expect(activity.id).toBeDefined();
expect(activity.payType).toBe(ActivityPayType.AA_PAY);
expect(activity.aaFeeCents).toBe(3000);
expect(activity.maxParticipants).toBe(4);
expect(activity.currentParticipants).toBe(0);
});
it('应成功创建项目制活动(不限制报名人数)', () => {
const activity = activityService.createActivity({
creatorId: 1002,
title: '水友粉丝狂欢内战赛',
gameCategory: '英雄联盟',
payType: ActivityPayType.PROJECT_PAY,
totalFeeCents: 100000, // 发起人项目总包1000元
maxParticipants: 0, // 不限制报名人数
});
expect(activity.payType).toBe(ActivityPayType.PROJECT_PAY);
expect(activity.totalFeeCents).toBe(100000);
expect(activity.maxParticipants).toBe(0);
});
});
describe('2. 报名与生成 4 位纯数字核销码', () => {
let aaActivity: Activity;
beforeEach(() => {
aaActivity = activityService.createActivity({
creatorId: 1001,
title: '和平精英四排吃鸡车队',
gameCategory: '和平精英',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 2500,
maxParticipants: 3,
});
});
it('报名成功后应生成合法的 4 位纯数字核销码 (1000-9999)', () => {
const participant = activityService.joinActivity(aaActivity.id, {
userId: 2001,
payAmountCents: 2500,
});
expect(participant.verifyCode).toMatch(/^\d{4}$/);
const codeNum = parseInt(participant.verifyCode, 10);
expect(codeNum).toBeGreaterThanOrEqual(1000);
expect(codeNum).toBeLessThanOrEqual(9999);
expect(participant.checkinStatus).toBe(false);
});
it('单活动内多个参与者的 4 位核销码不可重复碰撞', () => {
const p1 = activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
const p2 = activityService.joinActivity(aaActivity.id, { userId: 2002, payAmountCents: 2500 });
const p3 = activityService.joinActivity(aaActivity.id, { userId: 2003, payAmountCents: 2500 });
const codes = new Set([p1.verifyCode, p2.verifyCode, p3.verifyCode]);
expect(codes.size).toBe(3);
});
it('同一用户不可重复报名同一活动', () => {
activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
expect(() => {
activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
}).toThrow('用户已报名该活动');
});
it('超出人数限制时应阻止报名', () => {
activityService.joinActivity(aaActivity.id, { userId: 2001, payAmountCents: 2500 });
activityService.joinActivity(aaActivity.id, { userId: 2002, payAmountCents: 2500 });
activityService.joinActivity(aaActivity.id, { userId: 2003, payAmountCents: 2500 });
expect(() => {
activityService.joinActivity(aaActivity.id, { userId: 2004, payAmountCents: 2500 });
}).toThrow('活动报名人数已满');
});
});
describe('3. 4位核销码签到核销与防重控制', () => {
let testActivity: Activity;
let participant: Participant;
beforeEach(() => {
testActivity = activityService.createActivity({
creatorId: 1001,
title: '双排上分活动',
gameCategory: '王者荣耀',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 2000,
maxParticipants: 1,
});
participant = activityService.joinActivity(testActivity.id, {
userId: 3001,
payAmountCents: 2000,
});
});
it('发起人使用正确的 4 位核销码应核销成功', () => {
const result = activityService.verifyCheckin({
activityId: testActivity.id,
operatorId: 1001, // 发起人操作
verifyCode: participant.verifyCode,
});
expect(result.success).toBe(true);
expect(result.participant.checkinStatus).toBe(true);
expect(result.participant.verifiedAt).toBeDefined();
});
it('同一 4 位核销码不可重复核销(防重签到)', () => {
activityService.verifyCheckin({
activityId: testActivity.id,
operatorId: 1001,
verifyCode: participant.verifyCode,
});
expect(() => {
activityService.verifyCheckin({
activityId: testActivity.id,
operatorId: 1001,
verifyCode: participant.verifyCode,
});
}).toThrow('该核销码已被核销,不可重复使用');
});
it('输入错误的 4 位核销码应报错拒绝', () => {
expect(() => {
activityService.verifyCheckin({
activityId: testActivity.id,
operatorId: 1001,
verifyCode: '0000', // 不存在的核销码
});
}).toThrow('无效或不存在的核销码');
});
it('非发起人或未授权人员核销应被拒绝', () => {
expect(() => {
activityService.verifyCheckin({
activityId: testActivity.id,
operatorId: 9999, // 非法第三方操作者
verifyCode: participant.verifyCode,
});
}).toThrow('无权核销该活动的核销码');
});
});
describe('4. 同城线下陪玩·陪吃·陪喝组局活动', () => {
it('应成功发起线下同城火锅探店AA组局活动并完成现场核销', () => {
const mealAct = activityService.createActivity({
creatorId: 1003,
title: '【周末同城】三里屯火锅探店饭搭子4人AA局',
gameCategory: '同城陪吃',
location: '北京市朝阳区三里屯',
payType: ActivityPayType.AA_PAY,
aaFeeCents: 5000,
maxParticipants: 4,
});
expect(mealAct.location).toBe('北京市朝阳区三里屯');
const p = activityService.joinActivity(mealAct.id, {
userId: 2005,
payAmountCents: 5000,
});
expect(p.verifyCode).toMatch(/^\d{4}$/);
const check = activityService.verifyCheckin({
activityId: mealAct.id,
operatorId: 1003,
verifyCode: p.verifyCode,
});
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('已核销到场,无法申请退席');
});
});
});