This commit is contained in:
richarjiang
2026-07-15 17:12:04 +08:00
commit f8c8c688a1
26 changed files with 6789 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
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, {
courtId: query.courtId
})
expect(fetchMock).toHaveBeenCalledOnce()
const request = fetchMock.mock.calls[0]?.[1] as RequestInit
expect(request.headers).toEqual(expect.objectContaining({
STOREID: '6038652'
}))
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 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',
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' })
])
})
it('接口字段变化时明确失败,不静默丢时段', () => {
expect(() => parseAvailabilityResponse(
{
successed: true,
result: { slots: [{ classRoomName: '字段缺失' }] }
},
query
)).toThrow('无法识别的时段数据')
})
})