Files
tennis-book/src/main/adapters/fansibote-fuzhong/adapter.test.ts
2026-07-15 20:09:02 +08:00

336 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('无法识别的时段数据')
})
})