337 行
11 KiB
TypeScript
337 行
11 KiB
TypeScript
import {
|
|
CompanionOrderService,
|
|
CreateOrderParams
|
|
} from '../src/core/order/companion-order.service';
|
|
import { CompanionTrack, ExpenseBurdenType } from '../src/core/companion/service-category';
|
|
import { OrderStatus } from '../src/core/order/order-state-machine';
|
|
|
|
describe('CompanionOrderService (TDD 测试套件: 线上游戏与同城陪玩·陪吃·陪喝订单及现场核销闭环)', () => {
|
|
let orderService: CompanionOrderService;
|
|
|
|
beforeEach(() => {
|
|
orderService = new CompanionOrderService();
|
|
});
|
|
|
|
describe('1. 订单创建与差异化字段校验', () => {
|
|
it('成功创建线上游戏陪玩订单', () => {
|
|
const params: CreateOrderParams = {
|
|
buyerId: 1001,
|
|
expertId: 2001,
|
|
track: CompanionTrack.ONLINE_GAME,
|
|
serviceTitle: '王者荣耀 · 排位双排包星',
|
|
hours: 2,
|
|
totalAmountCents: 7000,
|
|
gameDetails: {
|
|
gameName: '王者荣耀',
|
|
gameId: '峡谷第一野王',
|
|
serverDistrict: '微信区',
|
|
},
|
|
};
|
|
|
|
const order = orderService.createOrder(params);
|
|
expect(order.id).toBeDefined();
|
|
expect(order.status).toBe(OrderStatus.PENDING_PAYMENT);
|
|
expect(order.track).toBe(CompanionTrack.ONLINE_GAME);
|
|
expect(order.gameDetails?.gameId).toBe('峡谷第一野王');
|
|
});
|
|
|
|
it('成功创建同城线下陪吃订单并指定消费AA分摊', () => {
|
|
const params: CreateOrderParams = {
|
|
buyerId: 1001,
|
|
expertId: 2002,
|
|
track: CompanionTrack.OFFLINE_MEAL,
|
|
serviceTitle: '同城美食探店 · 网红火锅饭搭子',
|
|
hours: 3,
|
|
totalAmountCents: 15000,
|
|
offlineDetails: {
|
|
city: '北京市',
|
|
venueName: '朝阳大悦城海底捞',
|
|
meetTime: '今晚 19:30',
|
|
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
|
},
|
|
};
|
|
|
|
const order = orderService.createOrder(params);
|
|
expect(order.track).toBe(CompanionTrack.OFFLINE_MEAL);
|
|
expect(order.offlineDetails?.venueName).toBe('朝阳大悦城海底捞');
|
|
expect(order.offlineDetails?.expenseBurden).toBe(ExpenseBurdenType.AA_SHARE);
|
|
});
|
|
|
|
it('成功创建同城量贩KTV陪唱订单并前置约定包厢消费全包', () => {
|
|
const params: CreateOrderParams = {
|
|
buyerId: 1001,
|
|
expertId: 2003,
|
|
track: CompanionTrack.OFFLINE_SING,
|
|
serviceTitle: '量贩KTV麦霸对唱与气氛陪伴',
|
|
hours: 3,
|
|
totalAmountCents: 24000,
|
|
offlineDetails: {
|
|
city: '北京市',
|
|
venueName: '工体纯K量贩KTV',
|
|
meetTime: '今晚 20:00',
|
|
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
|
},
|
|
};
|
|
|
|
const order = orderService.createOrder(params);
|
|
expect(order.track).toBe(CompanionTrack.OFFLINE_SING);
|
|
expect(order.offlineDetails?.venueName).toBe('工体纯K量贩KTV');
|
|
expect(order.offlineDetails?.expenseBurden).toBe(ExpenseBurdenType.BUYER_TREAT);
|
|
});
|
|
|
|
it('线下订单缺少商圈场所或城市时应抛出异常', () => {
|
|
expect(() => {
|
|
orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2002,
|
|
track: CompanionTrack.OFFLINE_DRINK,
|
|
serviceTitle: '星巴克手冲咖啡聊天',
|
|
hours: 2,
|
|
totalAmountCents: 10000,
|
|
offlineDetails: {
|
|
city: '',
|
|
venueName: '',
|
|
meetTime: '下午 15:00',
|
|
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
|
},
|
|
});
|
|
}).toThrow('线下陪伴订单必须提供有效服务城市与公开碰面场所');
|
|
});
|
|
});
|
|
|
|
describe('2. 线下订单 4 位现场核销码流转与履约签到', () => {
|
|
it('线下订单完成支付并被大神接单后,生成 4 位纯数字现场核销码', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2002,
|
|
track: CompanionTrack.OFFLINE_MEAL,
|
|
serviceTitle: '火锅探店',
|
|
hours: 2,
|
|
totalAmountCents: 12000,
|
|
offlineDetails: {
|
|
city: '北京市',
|
|
venueName: '大悦城餐厅',
|
|
meetTime: '19:00',
|
|
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
|
},
|
|
});
|
|
|
|
// 买家付款
|
|
orderService.payOrder(order.id);
|
|
expect(order.status).toBe(OrderStatus.PENDING_ACCEPT);
|
|
|
|
// 大神接单,系统为该订单生成4位现场核销码
|
|
orderService.expertAccept(order.id, 2002);
|
|
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM); // 等待现场核销确认进入服务
|
|
expect(order.offlineVerifyCode).toMatch(/^\d{4}$/);
|
|
});
|
|
|
|
it('大神在现场输入正确的 4 位核销码完成到场签到,进入服务中状态', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2002,
|
|
track: CompanionTrack.OFFLINE_DRINK,
|
|
serviceTitle: '下午茶清吧',
|
|
hours: 2,
|
|
totalAmountCents: 10000,
|
|
offlineDetails: {
|
|
city: '上海市',
|
|
venueName: '静安嘉里中心咖啡店',
|
|
meetTime: '15:00',
|
|
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
|
},
|
|
});
|
|
|
|
orderService.payOrder(order.id);
|
|
orderService.expertAccept(order.id, 2002);
|
|
|
|
const code = order.offlineVerifyCode!;
|
|
const checkinResult = orderService.verifyOfflineCheckin({
|
|
orderId: order.id,
|
|
operatorExpertId: 2002,
|
|
verifyCode: code,
|
|
});
|
|
|
|
expect(checkinResult.success).toBe(true);
|
|
expect(order.status).toBe(OrderStatus.IN_SERVICE);
|
|
expect(order.checkedInAt).toBeDefined();
|
|
});
|
|
|
|
it('输入错误的 4 位核销码应报错拒绝', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2002,
|
|
track: CompanionTrack.OFFLINE_PLAY,
|
|
serviceTitle: '桌游探案',
|
|
hours: 2,
|
|
totalAmountCents: 9000,
|
|
offlineDetails: {
|
|
city: '深圳市',
|
|
venueName: '万象天地桌游吧',
|
|
meetTime: '14:00',
|
|
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
|
},
|
|
});
|
|
|
|
orderService.payOrder(order.id);
|
|
orderService.expertAccept(order.id, 2002);
|
|
|
|
expect(() => {
|
|
orderService.verifyOfflineCheckin({
|
|
orderId: order.id,
|
|
operatorExpertId: 2002,
|
|
verifyCode: '0000', // 错误码
|
|
});
|
|
}).toThrow('现场核销码错误');
|
|
});
|
|
|
|
it('已核销订单不可重复核销(防重签到)', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2002,
|
|
track: CompanionTrack.OFFLINE_PLAY,
|
|
serviceTitle: '桌游探案',
|
|
hours: 2,
|
|
totalAmountCents: 9000,
|
|
offlineDetails: {
|
|
city: '深圳市',
|
|
venueName: '万象天地桌游吧',
|
|
meetTime: '14:00',
|
|
expenseBurden: ExpenseBurdenType.AA_SHARE,
|
|
},
|
|
});
|
|
|
|
orderService.payOrder(order.id);
|
|
orderService.expertAccept(order.id, 2002);
|
|
|
|
orderService.verifyOfflineCheckin({
|
|
orderId: order.id,
|
|
operatorExpertId: 2002,
|
|
verifyCode: order.offlineVerifyCode!,
|
|
});
|
|
|
|
// 再次核销
|
|
expect(() => {
|
|
orderService.verifyOfflineCheckin({
|
|
orderId: order.id,
|
|
operatorExpertId: 2002,
|
|
verifyCode: order.offlineVerifyCode!,
|
|
});
|
|
}).toThrow('该订单已完成现场核销签到');
|
|
});
|
|
|
|
it('非接单大神尝试核销应被拒绝', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2002,
|
|
track: CompanionTrack.OFFLINE_PLAY,
|
|
serviceTitle: '密室逃脱',
|
|
hours: 2,
|
|
totalAmountCents: 10000,
|
|
offlineDetails: {
|
|
city: '成都市',
|
|
venueName: '春熙路密室馆',
|
|
meetTime: '16:00',
|
|
expenseBurden: ExpenseBurdenType.BUYER_TREAT,
|
|
},
|
|
});
|
|
|
|
orderService.payOrder(order.id);
|
|
orderService.expertAccept(order.id, 2002);
|
|
|
|
expect(() => {
|
|
orderService.verifyOfflineCheckin({
|
|
orderId: order.id,
|
|
operatorExpertId: 9999, // 非该订单的大神
|
|
verifyCode: order.offlineVerifyCode!,
|
|
});
|
|
}).toThrow('无权核销该订单');
|
|
});
|
|
});
|
|
|
|
describe('3. 7.1 陪玩服务全生命周期超时自动流转与部分退款仲裁闭环', () => {
|
|
it('待付款订单超过 15 分钟未支付自动流转为已取消 (CANCELLED)', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2001,
|
|
track: CompanionTrack.ONLINE_GAME,
|
|
serviceTitle: '王者双排',
|
|
hours: 1,
|
|
totalAmountCents: 3500,
|
|
});
|
|
|
|
const future16Min = new Date(order.createdAt.getTime() + 16 * 60 * 1000);
|
|
const res = orderService.processOrderTimeouts(future16Min);
|
|
expect(res.cancelledIds).toContain(order.id);
|
|
expect(order.status).toBe(OrderStatus.CANCELLED);
|
|
});
|
|
|
|
it('待接单订单超过 30 分钟大神未接单自动流转为已退款 (REFUNDED)', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2001,
|
|
track: CompanionTrack.ONLINE_GAME,
|
|
serviceTitle: '王者双排',
|
|
hours: 1,
|
|
totalAmountCents: 3500,
|
|
});
|
|
orderService.payOrder(order.id, 1001);
|
|
expect(order.status).toBe(OrderStatus.PENDING_ACCEPT);
|
|
|
|
const future31Min = new Date((order.paidAt || order.createdAt).getTime() + 31 * 60 * 1000);
|
|
const res = orderService.processOrderTimeouts(future31Min);
|
|
expect(res.refundedIds).toContain(order.id);
|
|
expect(order.status).toBe(OrderStatus.REFUNDED);
|
|
expect(order.refundAmountCents).toBe(3500);
|
|
});
|
|
|
|
it('待确认订单超过 48 小时买家未确认自动流转为已完成 (COMPLETED)', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2001,
|
|
track: CompanionTrack.ONLINE_GAME,
|
|
serviceTitle: '王者双排',
|
|
hours: 2,
|
|
totalAmountCents: 7000,
|
|
});
|
|
orderService.payOrder(order.id, 1001);
|
|
orderService.expertAccept(order.id, 2001);
|
|
orderService.expertApplyFinish(order.id, 2001);
|
|
expect(order.status).toBe(OrderStatus.PENDING_CONFIRM);
|
|
|
|
const future49Hours = new Date((order.finishAppliedAt || order.createdAt).getTime() + 49 * 60 * 60 * 1000);
|
|
const res = orderService.processOrderTimeouts(future49Hours);
|
|
expect(res.autoCompletedIds).toContain(order.id);
|
|
expect(order.status).toBe(OrderStatus.COMPLETED);
|
|
});
|
|
|
|
it('争议仲裁支持客服判定部分退款 (PARTIAL_REFUND) 并流转为已退款', () => {
|
|
const order = orderService.createOrder({
|
|
buyerId: 1001,
|
|
expertId: 2001,
|
|
track: CompanionTrack.ONLINE_GAME,
|
|
serviceTitle: '王者三排',
|
|
hours: 2,
|
|
totalAmountCents: 10000,
|
|
});
|
|
orderService.payOrder(order.id, 1001);
|
|
orderService.expertAccept(order.id, 2001);
|
|
orderService.expertApplyFinish(order.id, 2001);
|
|
orderService.buyerDispute(order.id, 1001, '只打了一半时长申请部分退款');
|
|
|
|
const arbOrder = orderService.arbitrate({
|
|
orderId: order.id,
|
|
operatorAdminId: 9001,
|
|
decision: 'PARTIAL_REFUND',
|
|
refundAmountCents: 5000,
|
|
reason: '核实已完成1小时对局,判定退还买家一半费用',
|
|
});
|
|
|
|
expect(arbOrder.status).toBe(OrderStatus.REFUNDED);
|
|
expect(arbOrder.arbitrationDecision).toBe('PARTIAL_REFUND');
|
|
expect(arbOrder.refundAmountCents).toBe(5000);
|
|
});
|
|
});
|
|
});
|
|
|