336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||
import type { AvailabilityQuery } from '../../../shared/contracts'
|
||
import {
|
||
buildAvailabilityRequestBody,
|
||
buildCreateBookingRequestBody,
|
||
fansiboteFuzhongAdapter,
|
||
parseCreateBookingResponse,
|
||
parseAvailabilityResponse
|
||
} from './adapter'
|
||
|
||
const query: AvailabilityQuery = {
|
||
courtId: 'fansibote-fuzhong',
|
||
date: '2026-07-16'
|
||
}
|
||
|
||
function createSlot(status: number, canApptOrNot: boolean) {
|
||
return {
|
||
classroomUid: 1775556146897330236,
|
||
txtClassroomUid: '1775556146897330236',
|
||
classRoomName: '1号风雨场',
|
||
beginDatetime: '2026-07-16 08:00:00',
|
||
endDatetime: '2026-07-16 08:59:00',
|
||
cost: 80,
|
||
status,
|
||
apptInfo: {
|
||
canApptOrNot,
|
||
customerNames: ['王小球'],
|
||
hadVenueAppts: [{
|
||
customerName: '王小球',
|
||
customerTel: '13800000000'
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
|
||
describe('范思伯特福中福响应映射', () => {
|
||
afterEach(() => vi.unstubAllGlobals())
|
||
|
||
it('把内部场地 ID 映射为第三方 userId 字段', () => {
|
||
const body = buildAvailabilityRequestBody('2026-07-16')
|
||
|
||
expect(body).toEqual({
|
||
dateTime: '2026-07-16',
|
||
userId: 6038652,
|
||
projectUid: '1775556129325750199'
|
||
})
|
||
})
|
||
|
||
it('列表请求固定使用适配器私有的场地 ID', async () => {
|
||
const fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({ successed: true, result: { slots: [] } })
|
||
})
|
||
vi.stubGlobal('fetch', fetchMock)
|
||
|
||
await fansiboteFuzhongAdapter.getAvailability(query)
|
||
|
||
expect(fetchMock).toHaveBeenCalledOnce()
|
||
const request = fetchMock.mock.calls[0]?.[1] as RequestInit
|
||
expect(request.headers).toEqual(expect.objectContaining({
|
||
STOREID: '6038652'
|
||
}))
|
||
expect(request.headers).not.toHaveProperty('PSPLVISITORID')
|
||
expect(JSON.parse(String(request.body))).toEqual({
|
||
dateTime: '2026-07-16',
|
||
userId: 6038652,
|
||
projectUid: '1775556129325750199'
|
||
})
|
||
})
|
||
|
||
it('使用实时重新查询得到的场地引用和价格构造下单请求', () => {
|
||
const slot = parseAvailabilityResponse({
|
||
successed: true,
|
||
result: { slots: [createSlot(0, true)] }
|
||
}, query).slots[0]!
|
||
|
||
expect(buildCreateBookingRequestBody(slot)).toEqual({
|
||
userId: 6038652,
|
||
projectType: 0,
|
||
classroomItems: [{
|
||
classroomUid: '1775556146897330236',
|
||
beginDatetime: '2026-07-16 08:00:00',
|
||
endDatetime: '2026-07-16 08:59:00',
|
||
peopleNum: 1
|
||
}],
|
||
remark: '',
|
||
combinationPayments: [{ paymentMethod: 900, cost: 80 }]
|
||
})
|
||
})
|
||
|
||
it('把同一场地连续时段作为一笔组合订单提交并汇总金额', () => {
|
||
const first = createSlot(0, true)
|
||
const second = {
|
||
...createSlot(0, true),
|
||
beginDatetime: '2026-07-16 09:00:00',
|
||
endDatetime: '2026-07-16 09:59:00',
|
||
cost: 90
|
||
}
|
||
const slots = parseAvailabilityResponse({
|
||
successed: true,
|
||
result: { slots: [first, second] }
|
||
}, query).slots
|
||
|
||
expect(buildCreateBookingRequestBody(slots)).toEqual({
|
||
userId: 6038652,
|
||
projectType: 0,
|
||
classroomItems: [{
|
||
classroomUid: '1775556146897330236',
|
||
beginDatetime: '2026-07-16 08:00:00',
|
||
endDatetime: '2026-07-16 08:59:00',
|
||
peopleNum: 1
|
||
}, {
|
||
classroomUid: '1775556146897330236',
|
||
beginDatetime: '2026-07-16 09:00:00',
|
||
endDatetime: '2026-07-16 09:59:00',
|
||
peopleNum: 1
|
||
}],
|
||
remark: '',
|
||
combinationPayments: [{ paymentMethod: 900, cost: 170 }]
|
||
})
|
||
})
|
||
|
||
it('把下单成功响应映射为待支付锁场,不暴露支付脚本给公共订单', () => {
|
||
const slot = parseAvailabilityResponse({
|
||
successed: true,
|
||
result: { slots: [createSlot(0, true)] }
|
||
}, query).slots[0]!
|
||
const result = parseCreateBookingResponse({
|
||
successed: true,
|
||
result: {
|
||
apptUid: '1784102970857723008',
|
||
script: {
|
||
expireTime: '2026-07-15 16:24:31',
|
||
paySign: 'private-pay-sign'
|
||
},
|
||
venueAppointUids: ['1784102970857723008']
|
||
}
|
||
}, slot, query.courtId)
|
||
|
||
expect(result.booking).toEqual({
|
||
courtId: query.courtId,
|
||
orderId: '1784102970857723008',
|
||
venueAppointmentIds: ['1784102970857723008'],
|
||
amountCents: 8000,
|
||
courtLabel: '1号风雨场',
|
||
startTime: '08:00',
|
||
endTime: '08:59',
|
||
expiresAt: '2026-07-15 16:24:31',
|
||
status: 'pending-payment'
|
||
})
|
||
expect(JSON.stringify(result.booking)).not.toContain('private-pay-sign')
|
||
})
|
||
|
||
it('下单和取消都注入访客凭证,取消请求体严格为空对象', async () => {
|
||
const slot = parseAvailabilityResponse({
|
||
successed: true,
|
||
result: { slots: [createSlot(0, true)] }
|
||
}, query).slots[0]!
|
||
const fetchMock = vi.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({
|
||
successed: true,
|
||
result: {
|
||
apptUid: 'order-1',
|
||
script: { expireTime: '2026-07-15 16:24:31' },
|
||
venueAppointUids: ['order-1']
|
||
}
|
||
})
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
json: async () => ({ successed: true })
|
||
})
|
||
vi.stubGlobal('fetch', fetchMock)
|
||
const settings = {
|
||
courtId: query.courtId,
|
||
visitorId: 'visitor-token'
|
||
}
|
||
|
||
await fansiboteFuzhongAdapter.createBooking!([slot], settings)
|
||
await fansiboteFuzhongAdapter.cancelBooking!(settings)
|
||
|
||
const createRequest = fetchMock.mock.calls[0]?.[1] as RequestInit
|
||
const cancelRequest = fetchMock.mock.calls[1]?.[1] as RequestInit
|
||
expect(createRequest.headers).toEqual(expect.objectContaining({
|
||
PSPLVISITORID: 'visitor-token',
|
||
STOREID: '6038652',
|
||
VERSIONINFO: 'NC|2026.07.13',
|
||
xweb_xhr: '1',
|
||
'Sec-Fetch-Site': 'cross-site',
|
||
'Sec-Fetch-Mode': 'cors',
|
||
'Sec-Fetch-Dest': 'empty',
|
||
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html',
|
||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||
'Accept-Encoding': 'gzip, deflate, br',
|
||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Mac MacWechat/WMPF MacWechat/3.8.7(0x13080712) UnifiedPCMacWechat(0xf2641b36) XWEB/25193'
|
||
}))
|
||
expect(cancelRequest.headers).toEqual(expect.objectContaining({
|
||
PSPLVISITORID: 'visitor-token',
|
||
STOREID: '6038652',
|
||
VERSIONINFO: 'NC|2026.07.13',
|
||
Referer: 'https://servicewechat.com/wx2088ca46b47d8be7/12/page-frame.html'
|
||
}))
|
||
expect(cancelRequest.body).toBe('{}')
|
||
})
|
||
|
||
it('未配置访客凭证时拒绝产生下单请求', async () => {
|
||
const slot = parseAvailabilityResponse({
|
||
successed: true,
|
||
result: { slots: [createSlot(0, true)] }
|
||
}, query).slots[0]!
|
||
const fetchMock = vi.fn()
|
||
vi.stubGlobal('fetch', fetchMock)
|
||
|
||
await expect(fansiboteFuzhongAdapter.createBooking!([slot], {
|
||
courtId: query.courtId
|
||
})).rejects.toThrow('PSPLVISITORID')
|
||
expect(fetchMock).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('保留字符串 UID、价格和时间,可订时段不携带预约人信息', () => {
|
||
const result = parseAvailabilityResponse(
|
||
{
|
||
successed: true,
|
||
result: { slots: [createSlot(0, true)] }
|
||
},
|
||
query
|
||
)
|
||
|
||
expect(result.slots).toEqual([
|
||
expect.objectContaining({
|
||
id: '1775556146897330236:2026-07-16 08:00:00:2026-07-16 08:59:00',
|
||
courtLabel: '1号风雨场',
|
||
startTime: '08:00',
|
||
endTime: '08:59',
|
||
priceCents: 8000,
|
||
status: 'available'
|
||
})
|
||
])
|
||
expect(JSON.stringify(result)).not.toContain('13800000000')
|
||
expect(JSON.stringify(result)).not.toContain('王小球')
|
||
})
|
||
|
||
it('区分已预约与排课', () => {
|
||
const result = parseAvailabilityResponse(
|
||
{
|
||
successed: true,
|
||
result: {
|
||
slots: [createSlot(1, false), createSlot(4, false)]
|
||
}
|
||
},
|
||
query
|
||
)
|
||
|
||
expect(result.slots.map(({ status, unavailableLabel, bookedBy }) => ({
|
||
status,
|
||
unavailableLabel,
|
||
bookedBy
|
||
}))).toEqual([
|
||
{
|
||
status: 'booked',
|
||
unavailableLabel: '已订',
|
||
bookedBy: [{ name: '王小球', phone: '13800000000' }]
|
||
},
|
||
{ status: 'blocked', unavailableLabel: '排课', bookedBy: undefined }
|
||
])
|
||
})
|
||
|
||
it('把跨小时活动合并到同一子场的每个对应时段', () => {
|
||
const firstSlot = createSlot(0, true)
|
||
const secondSlot = {
|
||
...createSlot(0, true),
|
||
beginDatetime: '2026-07-16 09:00:00',
|
||
endDatetime: '2026-07-16 09:59:00'
|
||
}
|
||
const result = parseAvailabilityResponse(
|
||
{
|
||
successed: true,
|
||
result: {
|
||
slots: [firstSlot, secondSlot],
|
||
enrollSlots: [{
|
||
txtClassroomUid: '1775556146897330236',
|
||
beginDatetime: '2026-07-16 08:00:00',
|
||
endDatetime: '2026-07-16 09:59:00',
|
||
enrollmentRule: {
|
||
uidTxt: '1784027073124705081',
|
||
title: '【2.5双打】福中福8-10',
|
||
beginDate: '2026-07-16 08:00:00',
|
||
endDate: '2026-07-16 10:00:00',
|
||
enrollmentEndDate: '2026-07-16 07:00:00',
|
||
enrollmentFee: 70,
|
||
enrollmentCount: 3,
|
||
enrollmentLimitNum: 4,
|
||
customerModels: [{ name: '星' }, { name: '浩哥' }]
|
||
}
|
||
}]
|
||
}
|
||
},
|
||
query
|
||
)
|
||
|
||
expect(result.slots.map((slot) => slot.enrollment)).toEqual([
|
||
{
|
||
id: '1784027073124705081',
|
||
title: '【2.5双打】福中福8-10',
|
||
startTime: '08:00',
|
||
endTime: '10:00',
|
||
feeCents: 7000,
|
||
enrolledCount: 3,
|
||
capacity: 4,
|
||
participantNames: ['星', '浩哥'],
|
||
enrollmentDeadline: '07:00'
|
||
},
|
||
expect.objectContaining({ id: '1784027073124705081' })
|
||
])
|
||
expect(result.slots.map(({ status, unavailableLabel }) => ({
|
||
status,
|
||
unavailableLabel
|
||
}))).toEqual([
|
||
{ status: 'booked', unavailableLabel: '已订' },
|
||
{ status: 'booked', unavailableLabel: '已订' }
|
||
])
|
||
})
|
||
|
||
it('接口字段变化时明确失败,不静默丢时段', () => {
|
||
expect(() => parseAvailabilityResponse(
|
||
{
|
||
successed: true,
|
||
result: { slots: [{ classRoomName: '字段缺失' }] }
|
||
},
|
||
query
|
||
)).toThrow('无法识别的时段数据')
|
||
})
|
||
})
|