1955 lines
72 KiB
TypeScript
1955 lines
72 KiB
TypeScript
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(),
|
||
},
|
||
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('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)
|
||
})
|
||
})
|
||
|
||
})
|