fix(booking): 允许管理员/老师通过同一接口取消任意预约

将 cancelBooking 改为接收 actor({ id, isAdmin }),服务端按 JWT role
分流:普通成员仅能取消自己的预约,管理员跳过 owner check。
history 备注自动按 actor 选择「学员/管理员」前缀,审计可追溯。

客户端无感,仍调 PUT /booking/:id/cancel,前端零改动。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
richarjiang
2026-09-09 19:50:41 +08:00
parent d941f1b6a9
commit 22407a7ff9
3 changed files with 110 additions and 19 deletions

View File

@@ -20,6 +20,10 @@ const MOCK_SLOT_ID = 'slot-001'
const MOCK_MEMBERSHIP_ID = 'mem-001'
const MOCK_BOOKING_ID = 'booking-001'
const MEMBER_ACTOR = { id: MOCK_USER_ID, isAdmin: false }
const MOCK_ADMIN_ID = 'admin-001'
const ADMIN_ACTOR = { id: MOCK_ADMIN_ID, isAdmin: true }
const mockTimesCardType = {
id: 'ct-times-001',
name: '10次卡',
@@ -762,7 +766,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
@@ -813,7 +817,7 @@ describe('BookingService', () => {
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
@@ -846,7 +850,7 @@ describe('BookingService', () => {
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
@@ -875,7 +879,7 @@ describe('BookingService', () => {
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
@@ -897,7 +901,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(false)
// membership.update must NOT be called
@@ -920,7 +924,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
// slot was FULL → should be restored to OPEN
expect(tx.timeSlot.update).toHaveBeenCalledWith(
@@ -955,7 +959,7 @@ describe('BookingService', () => {
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
@@ -971,7 +975,7 @@ describe('BookingService', () => {
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(
await expect(service.cancelBooking(MEMBER_ACTOR, 'nonexistent')).rejects.toThrow(
NotFoundException,
)
})
@@ -980,7 +984,7 @@ describe('BookingService', () => {
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(
await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
ForbiddenException,
)
})
@@ -994,10 +998,90 @@ describe('BookingService', () => {
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking)
await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow(
await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow(
BadRequestException,
)
})
// ─── Admin actor branch ─────────────────────────────────────────────────
it('admin actor can cancel another user\'s booking (skips owner check)', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(true)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: BookingStatus.CANCELLED }),
}),
)
})
it('admin actor records 管理员 remark in bookingStatusHistory', async () => {
const otherUserBooking = {
...mockConfirmedBooking,
userId: 'other-user',
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_ADMIN_ID,
remark: '管理员取消预约(超时退款)',
}),
}),
)
})
it('member actor records 学员 remark when cancelling own booking', async () => {
const ownBooking = {
...mockConfirmedBooking,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockActiveMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
operatorId: MOCK_USER_ID,
remark: '学员取消预约(超时退款)',
}),
}),
)
})
})
// ─── getMyBookings ────────────────────────────────────────────────────────