文件
shanghaipeiwan/server/tests/companion-order.spec.ts

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