feat(server): add booking, payment, and scheduler modules
Booking: reservation with atomic transactions, cancellation with refund logic based on cancelHoursLimit (23 tests) Payment: WeChat Pay integration (mock), order lifecycle, membership creation on payment callback (13 tests) Scheduler: cron tasks for slot generation, cleanup, membership expiry (8 tests) 109 total tests passing across 9 test suites
This commit is contained in:
710
packages/server/src/booking/__tests__/booking.service.spec.ts
Normal file
710
packages/server/src/booking/__tests__/booking.service.spec.ts
Normal file
@@ -0,0 +1,710 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } 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'
|
||||
|
||||
// ─── 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 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',
|
||||
cardTypeId: mockDurationCardType.id,
|
||||
remainingTimes: null,
|
||||
cardType: mockDurationCardType,
|
||||
}
|
||||
|
||||
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,
|
||||
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: [],
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// ─── Mock factory ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
timeSlot: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
membership: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test Suite ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BookingService', () => {
|
||||
let service: BookingService
|
||||
let prisma: jest.Mocked<PrismaService>
|
||||
let studioService: jest.Mocked<StudioService>
|
||||
|
||||
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(),
|
||||
},
|
||||
timeSlot: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
membership: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MembershipService,
|
||||
useValue: {
|
||||
deductMembership: jest.fn(),
|
||||
restoreMembership: jest.fn(),
|
||||
getValidMembership: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StudioService,
|
||||
useValue: {
|
||||
getInfo: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile()
|
||||
|
||||
service = module.get<BookingService>(BookingService)
|
||||
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
||||
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
||||
})
|
||||
|
||||
afterEach(() => jest.clearAllMocks())
|
||||
|
||||
// ─── createBooking ────────────────────────────────────────────────────────
|
||||
|
||||
describe('createBooking', () => {
|
||||
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
|
||||
|
||||
it('creates booking, increments bookedCount, and deducts membership (TIMES card)', 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)
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
// Mock the re-fetch after transaction
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
|
||||
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,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// bookedCount incremented from 0 → 1, still OPEN (capacity 5)
|
||||
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ bookedCount: 1, status: TimeSlotStatus.OPEN }),
|
||||
}),
|
||||
)
|
||||
|
||||
// membership deducted from 5 → 4
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 4,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('sets slot to FULL when bookedCount reaches capacity', 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)
|
||||
tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL })
|
||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL },
|
||||
membership: mockActiveMembership,
|
||||
})
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, dto)
|
||||
|
||||
// bookedCount 4+1 = 5 = capacity → FULL
|
||||
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ bookedCount: 5, status: TimeSlotStatus.FULL }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, durationDto)
|
||||
|
||||
// DURATION card: membership.update should NOT be called
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks membership as USED_UP when remainingTimes hits 0', async () => {
|
||||
const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 }
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(lastTimeMembership)
|
||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
tx.membership.update.mockResolvedValue({ ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: { ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP },
|
||||
})
|
||||
|
||||
await service.createBooking(MOCK_USER_ID, dto)
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 0,
|
||||
status: MembershipStatus.USED_UP,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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 BadRequestException when TIMES membership has 0 remaining', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 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(MOCK_USER_ID, 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('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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, 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(MOCK_USER_ID, '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(MOCK_USER_ID, 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(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 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: 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.findMany as jest.Mock).mockResolvedValue(bookings)
|
||||
;(prisma.booking.count as jest.Mock).mockResolvedValue(1)
|
||||
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user