perf: 优化订阅刷新逻辑
This commit is contained in:
@@ -129,6 +129,8 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
timeSlot: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
booking: {
|
||||
findUnique: jest.fn(),
|
||||
@@ -140,6 +142,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
bookingStatusHistory: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -1001,4 +1006,412 @@ describe('BookingService', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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 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,
|
||||
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 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).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('reuses an existing slot when arranging by date and time', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx)
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
})
|
||||
|
||||
expect(tx.timeSlot.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
date_startTime_endTime: {
|
||||
date: new Date('2099-12-31T00:00:00.000Z'),
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a manual slot when arranging a missing date and time', async () => {
|
||||
const tx = buildTxMock()
|
||||
const createdSlot = { ...mockOpenSlot, id: 'slot-manual-001', source: 'MANUAL' }
|
||||
tx.timeSlot.findUnique
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ ...createdSlot, bookedCount: 1 })
|
||||
tx.timeSlot.create.mockResolvedValue(createdSlot)
|
||||
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
|
||||
tx.booking.create.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlotId: createdSlot.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
timeSlotId: createdSlot.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: createdSlot,
|
||||
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.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
})
|
||||
|
||||
expect(tx.timeSlot.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
source: 'MANUAL',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects custom slots whose end time is not after start time', async () => {
|
||||
const tx = buildTxMock()
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(
|
||||
service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
userId: MOCK_USER_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
date: '2099-12-31',
|
||||
startTime: '23:00',
|
||||
endTime: '00:00',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException)
|
||||
expect(tx.timeSlot.findUnique).not.toHaveBeenCalled()
|
||||
expect(tx.timeSlot.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user