perf: 支持约课以及消息推送能力

This commit is contained in:
richarjiang
2026-04-12 21:44:44 +08:00
parent 9639f44698
commit c60821c5ff
28 changed files with 963 additions and 86 deletions

View File

@@ -10,6 +10,7 @@ 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'
// ─── Fixtures ──────────────────────────────────────────────────────────────
@@ -138,6 +139,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
findUnique: jest.fn(),
update: jest.fn(),
},
bookingStatusHistory: {
create: jest.fn(),
},
...overrides,
}
}
@@ -148,6 +152,7 @@ describe('BookingService', () => {
let service: BookingService
let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock }
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -172,6 +177,9 @@ describe('BookingService', () => {
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
},
{
@@ -188,35 +196,91 @@ describe('BookingService', () => {
getInfo: jest.fn(),
},
},
{
provide: SubscriptionMessageService,
useValue: {
sendBookingConfirmedMessage: 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)
})
afterEach(() => jest.clearAllMocks())
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',
bookingPeriod: '2099-12-31 09:00~10:00',
})
})
})
// ─── createBooking ────────────────────────────────────────────────────────
describe('createBooking', () => {
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
it('creates booking, increments bookedCount, and deducts membership (TIMES card)', async () => {
it('creates booking in pending confirmation status', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.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 })
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,
})
@@ -229,55 +293,45 @@ describe('BookingService', () => {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.CONFIRMED,
status: BookingStatus.PENDING_CONFIRMATION,
}),
}),
)
// 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(tx.timeSlot.update).not.toHaveBeenCalled()
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result).toBeDefined()
})
it('sets slot to FULL when bookedCount reaches capacity', async () => {
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.findFirst.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 })
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,
timeSlot: { ...nearFullSlot, status: TimeSlotStatus.FULL },
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: nearFullSlot,
membership: mockActiveMembership,
})
await service.createBooking(MOCK_USER_ID, dto)
// bookedCount 4+1 = 5 = capacity → FULL
expect(tx.timeSlot.update).toHaveBeenCalledWith(
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ bookedCount: 5, status: TimeSlotStatus.FULL }),
data: expect.objectContaining({
toStatus: BookingStatus.PENDING_CONFIRMATION,
operatorId: MOCK_USER_ID,
}),
}),
)
})
@@ -306,34 +360,29 @@ describe('BookingService', () => {
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('marks membership as USED_UP when remainingTimes hits 0', async () => {
const lastTimeMembership = { ...mockActiveMembership, remainingTimes: 1 }
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.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 })
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: { ...lastTimeMembership, remainingTimes: 0, status: MembershipStatus.USED_UP },
membership: mockMembershipNoTimes,
})
await service.createBooking(MOCK_USER_ID, dto)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}),
}),
)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('throws BadRequestException when slot is FULL', async () => {
@@ -374,20 +423,6 @@ describe('BookingService', () => {
)
})
it('throws BadRequestException when TIMES membership has 0 remaining', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findFirst.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)
@@ -662,7 +697,7 @@ describe('BookingService', () => {
expect.objectContaining({
where: expect.objectContaining({
userId: MOCK_USER_ID,
status: BookingStatus.CONFIRMED,
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
}),
orderBy: [
{ timeSlot: { date: 'asc' } },