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); }); }); });