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('无法识别的时段数据') }) })