Files
mp-pilates/packages/server/src/booking/__tests__/booking.service.spec.ts
2026-10-28 12:24:22 +08:00

1973 lines
73 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 { Test, TestingModule } from '@nestjs/testing'
import {
BadRequestException,
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common'
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus, UserRole } from '@mp-pilates/shared'
import { BookingService } from '../booking.service'
import { PrismaService } from '../../prisma/prisma.service'
import { MembershipService } from '../../membership/membership.service'
import { StudioService } from '../../studio/studio.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { InviteService } from '../../invite/invite.service'
// ─── Fixtures ──────────────────────────────────────────────────────────────
const MOCK_USER_ID = 'user-001'
const MOCK_SLOT_ID = 'slot-001'
const MOCK_MEMBERSHIP_ID = 'mem-001'
const MOCK_BOOKING_ID = 'booking-001'
const MEMBER_ACTOR = { id: MOCK_USER_ID, isAdmin: false }
const MOCK_ADMIN_ID = 'admin-001'
const ADMIN_ACTOR = { id: MOCK_ADMIN_ID, isAdmin: true }
const mockTimesCardType = {
id: 'ct-times-001',
name: '10次卡',
type: CardTypeCategory.TIMES,
totalTimes: 10,
durationDays: 180,
price: 150000,
originalPrice: null,
description: null,
isActive: true,
sortOrder: 0,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
const mockDurationCardType = {
...mockTimesCardType,
id: 'ct-duration-001',
name: '月卡',
type: CardTypeCategory.DURATION,
totalTimes: null,
}
const mockOpenSlot = {
id: MOCK_SLOT_ID,
date: new Date('2099-12-31'), // far future
startTime: '09:00',
endTime: '10:00',
capacity: 5,
bookedCount: 0,
status: TimeSlotStatus.OPEN,
source: 'TEMPLATE',
templateId: null,
createdAt: new Date(),
updatedAt: new Date(),
}
const mockFullSlot = {
...mockOpenSlot,
id: 'slot-full-001',
bookedCount: 5,
status: TimeSlotStatus.FULL,
}
const mockActiveMembership = {
id: MOCK_MEMBERSHIP_ID,
userId: MOCK_USER_ID,
cardTypeId: mockTimesCardType.id,
remainingTimes: 5,
startDate: new Date('2024-01-01'),
expireDate: new Date('2099-12-31'),
status: MembershipStatus.ACTIVE,
createdAt: new Date(),
updatedAt: new Date(),
cardType: mockTimesCardType,
}
const mockDurationMembership = {
...mockActiveMembership,
id: 'mem-duration-001',
expireDate: new Date('2100-01-01'),
cardTypeId: mockDurationCardType.id,
remainingTimes: null,
cardType: mockDurationCardType,
}
const mockLimitedDurationMembership = {
...mockDurationMembership,
id: 'mem-duration-limited-001',
remainingTimes: 5,
totalTimes: 10,
}
const mockExpiredMembership = {
...mockActiveMembership,
id: 'mem-expired-001',
status: MembershipStatus.EXPIRED,
}
const mockMembershipNoTimes = {
...mockActiveMembership,
id: 'mem-no-times-001',
remainingTimes: 0,
}
const mockConfirmedBooking = {
id: MOCK_BOOKING_ID,
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: true,
status: BookingStatus.CONFIRMED,
cancelledAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}
const mockStudioConfig = {
id: 'studio-001',
name: 'Test Studio',
logo: null,
bannerUrl: null,
address: '',
phone: '',
latitude: null,
longitude: null,
cancelHoursLimit: 2,
photos: [],
ownerIntroduction: '',
ownerCertifications: [],
updatedAt: new Date(),
}
// ─── Mock factory ─────────────────────────────────────────────────────────
function buildTxMock(overrides: Record<string, unknown> = {}) {
return {
$queryRaw: jest.fn(),
timeSlot: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
updateMany: jest.fn(),
},
booking: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
membership: {
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
membershipContract: {
findFirst: jest.fn().mockResolvedValue(null),
},
bookingStatusHistory: {
create: jest.fn(),
},
bodyPortraitAssessment: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
...overrides,
}
}
// ─── Test Suite ────────────────────────────────────────────────────────────
describe('BookingService', () => {
let service: BookingService
let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: {
sendBookingConfirmedMessage: jest.Mock
sendAdminBookingCreatedMessage: jest.Mock
sendBookingCancelledMessage: jest.Mock
sendClassReminderMessage: jest.Mock
}
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BookingService,
{
provide: PrismaService,
useValue: {
$transaction: jest.fn(),
booking: {
findUnique: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
groupBy: jest.fn(),
},
timeSlot: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
membership: {
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
},
},
{
provide: MembershipService,
useValue: {
deductMembership: jest.fn(),
restoreMembership: jest.fn(),
getValidMembership: jest.fn(),
},
},
{
provide: StudioService,
useValue: {
getInfo: jest.fn(),
},
},
{
provide: SubscriptionMessageService,
useValue: {
sendBookingConfirmedMessage: jest.fn(),
sendAdminBookingCreatedMessage: jest.fn(),
sendBookingCancelledMessage: jest.fn(),
sendClassReminderMessage: jest.fn(),
},
},
{
provide: InviteService,
useValue: {
recordQualifiedTrialBooking: jest.fn(),
},
},
],
}).compile()
service = module.get<BookingService>(BookingService)
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
studioService = module.get(StudioService) as jest.Mocked<StudioService>
subscriptionMessageService = module.get(SubscriptionMessageService)
inviteService = module.get(InviteService)
})
afterEach(() => jest.clearAllMocks())
describe('monthly period booking boundaries', () => {
const planned = { ...mockLimitedDurationMembership, startDate:new Date('2099-12-30T00:00:00Z'),expireDate:new Date('2100-01-29T00:00:00Z'),status:MembershipStatus.SCHEDULED }
it('lets a paid future period reserve only its own dates and keeps its quota separate',async()=>{
const tx=buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(planned)
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:planned.id})
expect(tx.booking.create).toHaveBeenCalledWith(expect.objectContaining({data:expect.objectContaining({membershipId:planned.id})}))
expect(tx.membership.update).not.toHaveBeenCalled()
})
it.each(['before','after'])('rejects a course %s the monthly period before writing a booking',async boundary=>{
const tx=buildTxMock()
const membership=boundary==='before'?{...planned,startDate:new Date('2100-01-01T00:00:00Z')}:{...planned,expireDate:new Date('2099-12-31T00:00:00Z')}
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(membership)
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
await expect(service.createBooking(MOCK_USER_ID,{timeSlotId:MOCK_SLOT_ID,membershipId:membership.id})).rejects.toThrow('周期')
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('deducts a reserved future period without activating it ahead of time',async()=>{
const tx=buildTxMock()
tx.booking.findUnique.mockResolvedValue({...mockConfirmedBooking,status:BookingStatus.PENDING_CONFIRMATION,timeSlot:mockOpenSlot,membership:planned,membershipId:planned.id})
tx.booking.update.mockResolvedValue(mockConfirmedBooking)
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({...mockConfirmedBooking,timeSlot:mockOpenSlot,membership:planned})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
studioService.getInfo.mockResolvedValue(mockStudioConfig as never)
await service.confirmBooking(MOCK_BOOKING_ID,MOCK_ADMIN_ID)
expect(tx.membership.update).toHaveBeenCalledWith({where:{id:planned.id},data:{remainingTimes:4,status:MembershipStatus.SCHEDULED}})
})
it('rejects administrative arrangement outside the selected period',async()=>{
const tx=buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({id:MOCK_USER_ID})
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({...planned,startDate:new Date('2100-01-01T00:00:00Z')})
;(prisma.$transaction as jest.Mock).mockImplementation(fn=>fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID,{userId:MOCK_USER_ID,timeSlotId:MOCK_SLOT_ID,membershipId:planned.id})).rejects.toThrow('周期')
expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
})
})
describe('confirmBooking', () => {
it('sends booking confirmed subscription message after admin confirmation', async () => {
const tx = buildTxMock({
bookingStatusHistory: { create: jest.fn() },
})
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date('2099-12-30T00:00:00Z'),
})
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1, status: TimeSlotStatus.OPEN })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date('2099-12-30T00:00:00Z'),
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalledWith({
openid: 'openid-001',
bookingId: MOCK_BOOKING_ID,
bookingContent: '预约已确认',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingEndTime: '2099-12-31 10:00',
})
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: true }),
}),
)
})
it('deducts a count-limited DURATION membership on confirmation', async () => {
const tx = buildTxMock()
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockLimitedDurationMembership,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
status: BookingStatus.CONFIRMED,
})
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
tx.membership.update.mockResolvedValue({
...mockLimitedDurationMembership,
remainingTimes: 4,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
membership: mockLimitedDurationMembership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue(mockStudioConfig)
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockLimitedDurationMembership.id },
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
}),
)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: true }),
}),
)
})
it('records no deduction for an unlimited DURATION membership on confirmation', async () => {
const tx = buildTxMock()
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockDurationMembership,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
membershipTimesDeducted: false,
status: BookingStatus.CONFIRMED,
})
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
membershipTimesDeducted: false,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
membership: mockDurationMembership,
})
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.membership.update).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: false }),
}),
)
})
})
describe('completeBooking', () => {
it('records qualified trial booking after completion', async () => {
const tx = buildTxMock({
bookingStatusHistory: { create: jest.fn() },
})
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
timeSlot: mockOpenSlot,
membership: {
...mockActiveMembership,
cardType: {
...mockTimesCardType,
type: CardTypeCategory.TRIAL,
},
},
})
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
status: BookingStatus.COMPLETED,
reviewReminderDueAt: expect.any(Date),
}),
}))
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
})
})
// ─── createBooking ────────────────────────────────────────────────────────
describe('createBooking', () => {
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
it('blocks booking until the purchase contract is signed', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.membershipContract.findFirst.mockResolvedValue({ id: 'contract-1' })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow('请先签署会员卡合同')
expect(tx.membershipContract.findFirst).toHaveBeenCalledWith(expect.objectContaining({
where: { membershipId: mockActiveMembership.id, status: 'PENDING_SIGN', requireBeforeBooking: true },
}))
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null) // no duplicate
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
// Mock the re-fetch after transaction
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
const result = await service.createBooking(MOCK_USER_ID, dto)
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: false,
status: BookingStatus.PENDING_CONFIRMATION,
}),
}),
)
expect(tx.timeSlot.update).not.toHaveBeenCalled()
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result).toBeDefined()
})
it('binds anonymous originAssessmentId to current user on booking create', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'anon-assess', userId: null })
tx.bodyPortraitAssessment.update.mockResolvedValue({ id: 'anon-assess', userId: MOCK_USER_ID })
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
originAssessmentId: 'anon-assess',
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
originAssessmentId: 'anon-assess',
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'anon-assess' })
expect(tx.bodyPortraitAssessment.update).toHaveBeenCalledWith({
where: { id: 'anon-assess' },
data: { userId: MOCK_USER_ID, status: 'CLAIMED', claimedAt: expect.any(Date) },
})
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
originAssessmentId: 'anon-assess',
}),
}),
)
})
it('rejects originAssessmentId belonging to another user', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.bodyPortraitAssessment.findUnique.mockResolvedValue({ id: 'other-assess', userId: 'someone-else' })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(
service.createBooking(MOCK_USER_ID, { ...dto, originAssessmentId: 'other-assess' }),
).rejects.toThrow('画像报告不属于当前用户')
})
it('records booking status history when user creates a booking', async () => {
const nearFullSlot = { ...mockOpenSlot, bookedCount: 4, capacity: 5 }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: nearFullSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: MOCK_USER_ID,
}),
}),
)
})
it('does NOT deduct membership for DURATION card', async () => {
const durationDto = { timeSlotId: MOCK_SLOT_ID, membershipId: mockDurationMembership.id }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
timeSlot: mockOpenSlot,
membership: mockDurationMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, durationDto)
// DURATION card: membership.update should NOT be called
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('allows time-based membership with zero remaining times and leaves deduction to admin confirmation', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockMembershipNoTimes.id,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockMembershipNoTimes.id,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockMembershipNoTimes,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('sends admin booking created subscription message to admins with remaining count', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
{ openid: 'admin-openid-1' },
])
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ nickname: 'Alice', phone: '13800000000' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendAdminBookingCreatedMessage.mockResolvedValue(true)
await service.createBooking(MOCK_USER_ID, dto)
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
role: UserRole.ADMIN,
adminBookingSubscriptionCount: { gt: 0 },
},
select: {
openid: true,
},
})
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).toHaveBeenCalledWith({
openid: 'admin-openid-1',
bookingId: MOCK_BOOKING_ID,
bookingContent: 'Alice已预约',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingEndTime: '2099-12-31 10:00',
})
})
it('throws BadRequestException when slot is FULL', async () => {
const fullDto = { timeSlotId: mockFullSlot.id, membershipId: MOCK_MEMBERSHIP_ID }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, fullDto)).rejects.toThrow(
BadRequestException,
)
})
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
ConflictException,
)
})
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
BadRequestException,
)
})
it('throws NotFoundException when timeSlot does not exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(null)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
NotFoundException,
)
})
it('throws ForbiddenException when membership belongs to another user', async () => {
const otherUserMembership = { ...mockActiveMembership, userId: 'other-user' }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
ForbiddenException,
)
})
it('reuses a cancelled booking record when booking the same slot again', async () => {
const cancelledBooking = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
membershipId: 'mem-old-001',
cancelledAt: new Date('2099-12-30T00:00:00Z'),
confirmedAt: new Date('2099-12-29T00:00:00Z'),
completedAt: null,
operatorId: 'admin-001',
}
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(cancelledBooking)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.update.mockResolvedValue({
...cancelledBooking,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
operatorId: null,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
})
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.booking.create).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith({
where: { id: MOCK_BOOKING_ID },
data: {
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: false,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
completedAt: null,
operatorId: null,
},
})
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
bookingId: MOCK_BOOKING_ID,
fromStatus: BookingStatus.CANCELLED,
toStatus: BookingStatus.PENDING_CONFIRMATION,
remark: '学员重新发起预约',
}),
}),
)
})
})
// ─── cancelBooking ────────────────────────────────────────────────────────
describe('cancelBooking', () => {
// Slot starts 24h from now → within the 2-hour limit → refund eligible
const futureDate = new Date(Date.now() + 24 * 3600 * 1000)
const futureSlot = {
...mockOpenSlot,
date: futureDate,
startTime: `${String(futureDate.getUTCHours()).padStart(2, '0')}:${String(futureDate.getUTCMinutes()).padStart(2, '0')}`,
}
// Slot starts 30 minutes from now → past the 2-hour limit → no refund
const imminentDate = new Date(Date.now() + 30 * 60 * 1000)
const imminentSlot = {
...mockOpenSlot,
id: 'slot-imminent-001',
date: imminentDate,
startTime: `${String(imminentDate.getUTCHours()).padStart(2, '0')}:${String(imminentDate.getUTCMinutes()).padStart(2, '0')}`,
}
beforeEach(() => {
studioService.getInfo.mockResolvedValue(mockStudioConfig)
})
it('cancels booking within limit: decrements bookedCount and refunds membership', async () => {
const bookingWithRelations = {
...mockConfirmedBooking,
timeSlot: { ...futureSlot, bookedCount: 3 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
const tx = buildTxMock()
const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }
tx.booking.update.mockResolvedValue(cancelledBooking)
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 2 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: BookingStatus.CANCELLED }),
}),
)
// bookedCount decremented: 3 → 2
expect(tx.timeSlot.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ bookedCount: 2 }),
}),
)
// Membership restored: 5 → 6
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ remainingTimes: 6 }),
}),
)
expect(result.refunded).toBe(true)
})
it('restores a count-limited DURATION membership when cancelled within the limit', async () => {
const limitedDurationMembership = {
...mockLimitedDurationMembership,
remainingTimes: 4,
}
const bookingWithRelations = {
...mockConfirmedBooking,
membershipId: limitedDurationMembership.id,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: limitedDurationMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: limitedDurationMembership.id,
status: BookingStatus.CANCELLED,
})
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({
...limitedDurationMembership,
remainingTimes: 5,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: limitedDurationMembership.id },
data: expect.objectContaining({
remainingTimes: 5,
status: MembershipStatus.ACTIVE,
}),
}),
)
expect(result.refunded).toBe(true)
})
it('does not restore an unlimited DURATION membership when cancelled within the limit', async () => {
const bookingWithRelations = {
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
membershipTimesDeducted: false,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockDurationMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
status: BookingStatus.CANCELLED,
})
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
})
it('does not refund an originally unlimited membership after it is changed to counted', async () => {
const bookingWithRelations = {
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
membershipTimesDeducted: false,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: {
...mockLimitedDurationMembership,
remainingTimes: 10,
},
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
membershipTimesDeducted: false,
status: BookingStatus.CANCELLED,
})
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
})
it('cancels booking past limit: does NOT refund membership', async () => {
const bookingWithImminent = {
...mockConfirmedBooking,
timeSlot: { ...imminentSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithImminent)
const tx = buildTxMock()
const cancelledBooking = { ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }
tx.booking.update.mockResolvedValue(cancelledBooking)
tx.timeSlot.update.mockResolvedValue({ ...imminentSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(false)
// membership.update must NOT be called
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('changes slot from FULL to OPEN after cancellation', async () => {
const fullSlotWithBooking = {
...mockConfirmedBooking,
timeSlot: { ...futureSlot, bookedCount: 5, capacity: 5, status: TimeSlotStatus.FULL },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(fullSlotWithBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 4, status: TimeSlotStatus.OPEN })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
// slot was FULL → should be restored to OPEN
expect(tx.timeSlot.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
bookedCount: 4,
status: TimeSlotStatus.OPEN,
}),
}),
)
})
it('restores USED_UP membership back to ACTIVE when cancelled within limit', async () => {
const usedUpMembership = {
...mockActiveMembership,
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}
const bookingWithUsedUp = {
...mockConfirmedBooking,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: usedUpMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithUsedUp)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...usedUpMembership, remainingTimes: 1, status: MembershipStatus.ACTIVE })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 1,
status: MembershipStatus.ACTIVE,
}),
}),
)
expect(result.refunded).toBe(true)
})
it('throws NotFoundException when booking does not exist', async () => {
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null)
await expect(service.cancelBooking(MEMBER_ACTOR, 'nonexistent')).rejects.toThrow(
NotFoundException,
)
})
it('throws ForbiddenException when booking belongs to another user', async () => {
const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership }
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking)
await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
ForbiddenException,
)
})
it('throws BadRequestException when booking is already CANCELLED', async () => {
const cancelledBooking = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
timeSlot: futureSlot,
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking)
await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
BadRequestException,
)
})
// ─── Admin actor branch ─────────────────────────────────────────────────
it('admin actor can cancel another user\'s booking (skips owner check)', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(true)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: BookingStatus.CANCELLED }),
}),
)
})
it('admin actor records 管理员 remark in bookingStatusHistory', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_ADMIN_ID,
remark: '管理员取消预约(超时退款)',
}),
}),
)
})
it('member actor records 学员 remark when cancelling own booking', async () => {
const ownBooking = {
...mockConfirmedBooking,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_USER_ID,
remark: '学员取消预约(超时退款)',
}),
}),
)
})
it('triggers sendBookingCancelledMessage when a booking is cancelled', async () => {
const futureDate = new Date(Date.now() + 86400000 * 2)
const futureSlot = { ...mockOpenSlot, date: futureDate, startTime: '14:00', endTime: '15:00' }
const ownBooking = {
...mockConfirmedBooking,
userId: MOCK_USER_ID,
timeSlot: futureSlot,
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: MOCK_USER_ID, openid: 'test-user-openid' } as any)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(subscriptionMessageService.sendBookingCancelledMessage).toHaveBeenCalledWith(
expect.objectContaining({
openid: 'test-user-openid',
userId: MOCK_USER_ID,
bookingId: MOCK_BOOKING_ID,
courseName: 'Test Studio',
}),
)
})
})
// ─── getMyBookings ────────────────────────────────────────────────────────
describe('getMyBookings', () => {
it('returns paginated list of bookings for the user', async () => {
const bookings = [
{ ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: mockActiveMembership },
]
;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings)
;(prisma.booking.count as jest.Mock).mockResolvedValue(1)
const result = await service.getMyBookings(MOCK_USER_ID)
expect(result.total).toBe(1)
expect(result.page).toBe(1)
expect(result.limit).toBe(10)
expect(result.data).toHaveLength(1)
})
it('filters by status when provided', async () => {
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
;(prisma.booking.count as jest.Mock).mockResolvedValue(0)
await service.getMyBookings(MOCK_USER_ID, BookingStatus.CANCELLED)
expect(prisma.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: BookingStatus.CANCELLED,
}),
}),
)
})
it('uses default pagination when page/limit not provided', async () => {
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
;(prisma.booking.count as jest.Mock).mockResolvedValue(0)
const result = await service.getMyBookings(MOCK_USER_ID)
expect(prisma.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 0, take: 10 }),
)
expect(result.page).toBe(1)
expect(result.limit).toBe(10)
})
})
// ─── getUpcomingBookings ──────────────────────────────────────────────────
describe('getUpcomingBookings', () => {
it('returns confirmed bookings with future dates, ordered by date and startTime', async () => {
const upcoming = [
{ ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: mockActiveMembership },
]
;(prisma.booking.findMany as jest.Mock).mockResolvedValue(upcoming)
const result = await service.getUpcomingBookings(MOCK_USER_ID)
expect(prisma.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: MOCK_USER_ID,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
}),
orderBy: [
{ timeSlot: { date: 'asc' } },
{ timeSlot: { startTime: 'asc' } },
],
}),
)
expect(result).toHaveLength(1)
})
})
// ─── getAllBookings (admin) ────────────────────────────────────────────────
describe('getAllBookings', () => {
it('returns paginated list of all bookings with user info', async () => {
const bookings = [
{
...mockConfirmedBooking,
user: { id: MOCK_USER_ID, nickname: 'Test User', phone: null },
timeSlot: mockOpenSlot,
membership: mockActiveMembership,
},
]
;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([
{ status: BookingStatus.CONFIRMED, _count: { _all: 1 } },
])
;(prisma.booking.findMany as jest.Mock).mockResolvedValue(bookings)
const result = await service.getAllBookings(1, 10)
expect(result.total).toBe(1)
expect(result.data[0]).toHaveProperty('user')
})
it('filters by status when provided', async () => {
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
;(prisma.booking.count as jest.Mock).mockResolvedValue(0)
await service.getAllBookings(1, 10, BookingStatus.CONFIRMED)
expect(prisma.booking.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: BookingStatus.CONFIRMED },
}),
)
})
it('sorts the unfiltered list by status priority and pages across segments', async () => {
// 全部视图:待确认 → 已确认 → 已完成 → 已取消,未到归入已完成之后
;(prisma.booking.groupBy as jest.Mock).mockResolvedValue([
{ status: BookingStatus.PENDING_CONFIRMATION, _count: { _all: 1 } },
{ status: BookingStatus.CONFIRMED, _count: { _all: 2 } },
{ status: BookingStatus.COMPLETED, _count: { _all: 1 } },
{ status: BookingStatus.CANCELLED, _count: { _all: 1 } },
])
const byStatus: Record<string, { id: string }[]> = {
[BookingStatus.PENDING_CONFIRMATION]: [{ id: 'b-pending' }],
[BookingStatus.CONFIRMED]: [{ id: 'b-confirmed-1' }, { id: 'b-confirmed-2' }],
[BookingStatus.COMPLETED]: [{ id: 'b-completed' }],
[BookingStatus.CANCELLED]: [{ id: 'b-cancelled' }],
}
;(prisma.booking.findMany as jest.Mock).mockImplementation(
(args: { where: { status: string } }) => byStatus[args.where.status] ?? [],
)
const page1 = await service.getAllBookings(1, 3)
expect(page1.total).toBe(5)
expect(page1.data.map((b) => b.id)).toEqual(['b-pending', 'b-confirmed-1', 'b-confirmed-2'])
const page2 = await service.getAllBookings(2, 3)
expect(page2.data.map((b) => b.id)).toEqual(['b-completed', 'b-cancelled'])
// 段内排序:已确认按上课时间正序,其余按创建时间倒序
const confirmedCall = (prisma.booking.findMany as jest.Mock).mock.calls.find(
([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.CONFIRMED,
)
expect(confirmedCall[0].orderBy).toEqual([
{ timeSlot: { date: 'asc' } },
{ timeSlot: { startTime: 'asc' } },
])
const pendingCall = (prisma.booking.findMany as jest.Mock).mock.calls.find(
([args]: [{ where: { status: string } }]) => args.where.status === BookingStatus.PENDING_CONFIRMATION,
)
expect(pendingCall[0].orderBy).toEqual({ createdAt: 'desc' })
})
})
describe('getTeachingScheduleByDate', () => {
it('returns sorted slots with active students only', async () => {
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
{
id: 'slot-02',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
capacity: 1,
bookings: [
{
id: 'booking-02',
status: BookingStatus.CONFIRMED,
createdAt: new Date('2026-04-19T01:00:00Z'),
user: { id: 'user-02', nickname: '李四', phone: '13800000000' },
},
],
},
{
id: 'slot-01',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
capacity: 2,
bookings: [
{
id: 'booking-01',
status: BookingStatus.PENDING_CONFIRMATION,
createdAt: new Date('2026-04-19T00:00:00Z'),
user: { id: 'user-01', nickname: '张三', phone: null },
},
],
},
])
const result = await service.getTeachingScheduleByDate('2099-12-31')
expect(prisma.timeSlot.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
bookings: {
some: {
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
},
},
}),
orderBy: [
{ startTime: 'asc' },
{ endTime: 'asc' },
],
}),
)
expect(result).toEqual([
{
slotId: 'slot-01',
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
bookedCount: 2,
capacity: 2,
students: [
{
bookingId: 'booking-01',
userId: 'user-01',
nickname: '张三',
phone: null,
status: BookingStatus.PENDING_CONFIRMATION,
},
],
},
{
slotId: 'slot-02',
date: '2099-12-31',
startTime: '11:00',
endTime: '12:00',
bookedCount: 1,
capacity: 1,
students: [
{
bookingId: 'booking-02',
userId: 'user-02',
nickname: '李四',
phone: '13800000000',
status: BookingStatus.CONFIRMED,
},
],
},
])
})
it('returns all-status bookings when the date is today', async () => {
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
;(prisma.timeSlot.findMany as jest.Mock).mockResolvedValue([
{
id: 'slot-today',
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
bookings: [
{
id: 'booking-completed',
status: BookingStatus.COMPLETED,
createdAt: new Date(`${today}T00:00:00Z`),
user: { id: 'user-1', nickname: '完成', phone: '13800000001' },
},
{
id: 'booking-no-show',
status: BookingStatus.NO_SHOW,
createdAt: new Date(`${today}T00:00:01Z`),
user: { id: 'user-2', nickname: '未到', phone: null },
},
{
id: 'booking-cancelled',
status: BookingStatus.CANCELLED,
createdAt: new Date(`${today}T00:00:02Z`),
user: { id: 'user-3', nickname: '取消', phone: '13800000003' },
},
{
id: 'booking-confirmed',
status: BookingStatus.CONFIRMED,
createdAt: new Date(`${today}T00:00:03Z`),
user: { id: 'user-4', nickname: '确认', phone: null },
},
],
},
])
const result = await service.getTeachingScheduleByDate(today)
// 当日课表:timeSlot 仍用 EXISTS 守卫过滤完全没人预约的空 slot(不带状态条件),
// 且 included bookings 不再限制状态。
const callArg = (prisma.timeSlot.findMany as jest.Mock).mock.calls[0][0]
expect(callArg.where).toEqual({
date: expect.any(Date),
bookings: { some: {} },
})
expect(callArg.include.bookings.where).toBeUndefined()
expect(result).toEqual([
{
slotId: 'slot-today',
date: today,
startTime: '10:00',
endTime: '11:00',
bookedCount: 2,
capacity: 3,
students: [
{
bookingId: 'booking-completed',
userId: 'user-1',
nickname: '完成',
phone: '13800000001',
status: BookingStatus.COMPLETED,
},
{
bookingId: 'booking-no-show',
userId: 'user-2',
nickname: '未到',
phone: null,
status: BookingStatus.NO_SHOW,
},
{
bookingId: 'booking-cancelled',
userId: 'user-3',
nickname: '取消',
phone: '13800000003',
status: BookingStatus.CANCELLED,
},
{
bookingId: 'booking-confirmed',
userId: 'user-4',
nickname: '确认',
phone: null,
status: BookingStatus.CONFIRMED,
},
],
},
])
})
it('rejects invalid date input', async () => {
await expect(service.getTeachingScheduleByDate('invalid-date')).rejects.toThrow(
BadRequestException,
)
})
})
describe('adminArrangeBooking', () => {
const MOCK_ADMIN_ID = 'admin-001'
const dto = {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
}
const mockTrialCardType = {
...mockTimesCardType,
id: 'ct-trial-001',
name: '体验卡',
type: CardTypeCategory.TRIAL,
totalTimes: 1,
}
const mockTrialMembership = {
...mockActiveMembership,
id: 'mem-trial-001',
cardTypeId: mockTrialCardType.id,
remainingTimes: 1,
cardType: mockTrialCardType,
}
function stubArrangeSuccess(
tx: ReturnType<typeof buildTxMock>,
options?: {
membership?: typeof mockActiveMembership
| typeof mockDurationMembership
| typeof mockLimitedDurationMembership
| typeof mockTrialMembership
slot?: typeof mockOpenSlot
existing?: typeof mockConfirmedBooking | null
},
) {
const membership = options?.membership ?? mockActiveMembership
const slot = options?.slot ?? mockOpenSlot
const existing = options?.existing ?? null
const arranged = {
...mockConfirmedBooking,
membershipId: membership.id,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date(),
operatorId: MOCK_ADMIN_ID,
}
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.timeSlot.findUnique
.mockResolvedValueOnce(slot)
.mockResolvedValueOnce({ ...slot, bookedCount: slot.bookedCount + 1 })
tx.booking.findUnique.mockResolvedValue(existing)
tx.membership.findUnique.mockResolvedValue(membership)
tx.booking.create.mockResolvedValue(arranged)
tx.booking.update.mockResolvedValue(arranged)
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
tx.timeSlot.update.mockResolvedValue({ ...slot, bookedCount: slot.bookedCount + 1 })
tx.membership.update.mockResolvedValue({
...membership,
remainingTimes: membership.remainingTimes == null ? null : membership.remainingTimes - 1,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...arranged,
timeSlot: slot,
membership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
return arranged
}
it('creates a confirmed times-card booking and deducts one session', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx)
const result = await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: true,
status: BookingStatus.CONFIRMED,
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ remainingTimes: 4, status: MembershipStatus.ACTIVE }),
}),
)
expect(tx.timeSlot.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: MOCK_SLOT_ID,
status: TimeSlotStatus.OPEN,
bookedCount: { lt: mockOpenSlot.capacity },
}),
data: { bookedCount: { increment: 1 } },
}),
)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
toStatus: BookingStatus.CONFIRMED,
remark: '老师代为安排',
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalled()
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).not.toHaveBeenCalled()
expect(result.status).toBe(BookingStatus.CONFIRMED)
})
it('does not deduct remaining times for unlimited duration cards', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockDurationMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockDurationMembership.id,
})
expect(tx.membership.update).not.toHaveBeenCalled()
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: false }),
}),
)
})
it('deducts remaining times for count-limited duration cards', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockLimitedDurationMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockLimitedDurationMembership.id,
})
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockLimitedDurationMembership.id },
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
}),
)
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: true }),
}),
)
})
it('deducts a trial card session', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockTrialMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockTrialMembership.id,
})
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}),
}),
)
})
it('rejects when the times card has no remaining sessions', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('rejects when a duration card has expired', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockDurationMembership,
expireDate: new Date('2020-01-01'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(
service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockDurationMembership.id,
}),
).rejects.toThrow(BadRequestException)
})
it('rejects when the time slot is full', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.membership.findUnique).not.toHaveBeenCalled()
})
it('rejects duplicate active bookings for the same slot', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
ConflictException,
)
})
it('rejects arranging a past time slot', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue({
...mockOpenSlot,
date: new Date('2020-01-01T00:00:00Z'),
startTime: '09:00',
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
})
it('revives a cancelled booking instead of creating a new row', async () => {
const tx = buildTxMock()
const cancelled = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
}
stubArrangeSuccess(tx, { existing: cancelled })
await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
expect(tx.booking.create).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: cancelled.id },
data: expect.objectContaining({
status: BookingStatus.CONFIRMED,
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(tx.timeSlot.updateMany).toHaveBeenCalled()
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ remainingTimes: 4 }),
}),
)
})
it('rejects when the member does not exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue(null)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
NotFoundException,
)
})
it('rejects when the membership belongs to another member', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockActiveMembership,
userId: 'other-user',
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
ForbiddenException,
)
})
it('rejects an expired times card even if remaining sessions exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockActiveMembership,
expireDate: new Date('2020-01-01'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
})
it('rejects when occupancy update races and the slot is already full', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.timeSlot.updateMany.mockResolvedValue({ count: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
})
describe('getPracticeActivity', () => {
afterEach(() => jest.restoreAllMocks())
it('uses China today across UTC midnight and counts scheduled dates without pagination', async () => {
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-09-07T16:01:00Z'))
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([
{ timeSlot: { date: new Date('2026-08-10T00:00:00Z') } },
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
{ timeSlot: { date: new Date('2026-09-08T00:00:00Z') } },
])
const result = await service.getPracticeActivity(MOCK_USER_ID)
expect(result.days).toHaveLength(30)
expect(result.days[0]).toEqual({ date: '2026-08-10', count: 1 })
expect(result.days[29]).toEqual({ date: '2026-09-08', count: 2 })
expect(result.days[1]).toEqual({ date: '2026-08-11', count: 0 })
expect(prisma.booking.findMany).toHaveBeenCalledWith({
where: {
userId: MOCK_USER_ID,
status: BookingStatus.COMPLETED,
timeSlot: { date: {
gte: new Date('2026-08-10T00:00:00Z'),
lt: new Date('2026-09-09T00:00:00Z'),
} },
},
select: { timeSlot: { select: { date: true } } },
})
})
it('returns every day with zero counts across a leap-year boundary', async () => {
jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2024-03-01T01:00:00Z'))
;(prisma.booking.findMany as jest.Mock).mockResolvedValue([])
const result = await service.getPracticeActivity(MOCK_USER_ID)
expect(result.days).toHaveLength(30)
expect(result.days[0].date).toBe('2024-02-01')
expect(result.days[28].date).toBe('2024-02-29')
expect(result.days[29].date).toBe('2024-03-01')
expect(result.days.every(day => day.count === 0)).toBe(true)
})
})
})