fix(server): support rebooking cancelled slots

This commit is contained in:
richarjiang
2026-04-13 17:50:14 +08:00
parent f78cdcc9d1
commit d45a5b2c14
3 changed files with 113 additions and 36 deletions

View File

@@ -257,7 +257,7 @@ describe('BookingService', () => {
bookingContent: '预约已确认',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00',
bookingEndTime: '2099-12-31 10:00',
})
})
})
@@ -270,7 +270,7 @@ describe('BookingService', () => {
it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null) // no duplicate
tx.booking.findUnique.mockResolvedValue(null) // no duplicate
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -312,7 +312,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -345,7 +345,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
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 })
@@ -368,7 +368,7 @@ describe('BookingService', () => {
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.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -394,7 +394,7 @@ describe('BookingService', () => {
it('sends admin booking created subscription message to admins with remaining count', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
@@ -435,7 +435,7 @@ describe('BookingService', () => {
bookingContent: 'Alice已预约',
bookingTime: '2099-12-31 09:00',
courseName: 'FocusCore Pilates',
bookingPeriod: '2099-12-31 09:00~10:00',
bookingEndTime: '2099-12-31 10:00',
})
})
@@ -455,7 +455,7 @@ describe('BookingService', () => {
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(mockConfirmedBooking) // duplicate exists
tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -467,7 +467,7 @@ describe('BookingService', () => {
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -493,7 +493,7 @@ describe('BookingService', () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.mockResolvedValue(null)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
@@ -502,6 +502,65 @@ describe('BookingService', () => {
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,
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 ────────────────────────────────────────────────────────