perf: 支持约课以及消息推送能力
This commit is contained in:
@@ -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' } },
|
||||
|
||||
@@ -3,9 +3,10 @@ import { BookingController } from './booking.controller'
|
||||
import { BookingService } from './booking.service'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
import { StudioModule } from '../studio/studio.module'
|
||||
import { UserModule } from '../user/user.module'
|
||||
|
||||
@Module({
|
||||
imports: [MembershipModule, StudioModule],
|
||||
imports: [MembershipModule, StudioModule, UserModule],
|
||||
controllers: [BookingController],
|
||||
providers: [BookingService],
|
||||
exports: [BookingService],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } fro
|
||||
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 { CreateBookingDto } from './dto/create-booking.dto'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
@@ -48,6 +49,7 @@ export class BookingService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly membershipService: MembershipService,
|
||||
private readonly studioService: StudioService,
|
||||
private readonly subscriptionMessageService: SubscriptionMessageService,
|
||||
) {}
|
||||
|
||||
// ─── Create Booking ──────────────────────────────────────────────────────
|
||||
@@ -235,7 +237,9 @@ export class BookingService {
|
||||
return updated
|
||||
})
|
||||
|
||||
return this.fetchBookingWithRelations(booking.id)
|
||||
const confirmedBooking = await this.fetchBookingWithRelations(booking.id)
|
||||
await this.trySendBookingConfirmedSubscriptionMessage(confirmedBooking)
|
||||
return confirmedBooking
|
||||
}
|
||||
|
||||
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||
@@ -566,4 +570,34 @@ export class BookingService {
|
||||
|
||||
return { ...booking } as BookingWithRelations
|
||||
}
|
||||
|
||||
private async trySendBookingConfirmedSubscriptionMessage(
|
||||
booking: BookingWithRelations,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: booking.userId },
|
||||
select: { openid: true },
|
||||
})
|
||||
if (!user?.openid) {
|
||||
return
|
||||
}
|
||||
|
||||
const studio = await this.studioService.getInfo()
|
||||
const bookingDate = booking.timeSlot.date
|
||||
const dateLabel = `${bookingDate.getFullYear()}-${String(bookingDate.getMonth() + 1).padStart(2, '0')}-${String(bookingDate.getDate()).padStart(2, '0')}`
|
||||
const periodLabel = `${booking.timeSlot.startTime.slice(0, 5)}~${booking.timeSlot.endTime.slice(0, 5)}`
|
||||
|
||||
await this.subscriptionMessageService.sendBookingConfirmedMessage({
|
||||
openid: user.openid,
|
||||
bookingId: booking.id,
|
||||
bookingContent: '预约已确认',
|
||||
bookingTime: `${dateLabel} ${booking.timeSlot.startTime.slice(0, 5)}`,
|
||||
courseName: studio.name || '普拉提课程',
|
||||
bookingPeriod: `${dateLabel} ${periodLabel}`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Send booking confirmed subscription message failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user