fix: 修复月卡无法编辑次数的问题

This commit is contained in:
richarjiang
2026-09-07 18:10:39 +08:00
parent 86ad9ee64f
commit 87d946adb5
17 changed files with 564 additions and 91 deletions

View File

@@ -85,6 +85,13 @@ const mockDurationMembership = {
cardType: mockDurationCardType,
}
const mockLimitedDurationMembership = {
...mockDurationMembership,
id: 'mem-duration-limited-001',
remainingTimes: 5,
totalTimes: 10,
}
const mockExpiredMembership = {
...mockActiveMembership,
id: 'mem-expired-001',
@@ -102,6 +109,7 @@ const mockConfirmedBooking = {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: true,
status: BookingStatus.CONFIRMED,
cancelledAt: null,
createdAt: new Date(),
@@ -274,6 +282,95 @@ describe('BookingService', () => {
courseName: 'FocusCore Pilates',
bookingEndTime: '2099-12-31 10:00',
})
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: true }),
}),
)
})
it('deducts a count-limited DURATION membership on confirmation', async () => {
const tx = buildTxMock()
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockLimitedDurationMembership,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
status: BookingStatus.CONFIRMED,
})
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
tx.membership.update.mockResolvedValue({
...mockLimitedDurationMembership,
remainingTimes: 4,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
membership: mockLimitedDurationMembership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue(mockStudioConfig)
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockLimitedDurationMembership.id },
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
}),
)
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: true }),
}),
)
})
it('records no deduction for an unlimited DURATION membership on confirmation', async () => {
const tx = buildTxMock()
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
status: BookingStatus.PENDING_CONFIRMATION,
timeSlot: mockOpenSlot,
membership: mockDurationMembership,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
membershipTimesDeducted: false,
status: BookingStatus.CONFIRMED,
})
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
membershipTimesDeducted: false,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
membership: mockDurationMembership,
})
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
expect(tx.membership.update).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: false }),
}),
)
})
})
@@ -349,6 +446,7 @@ describe('BookingService', () => {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: false,
status: BookingStatus.PENDING_CONFIRMATION,
}),
}),
@@ -596,6 +694,7 @@ describe('BookingService', () => {
where: { id: MOCK_BOOKING_ID },
data: {
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: false,
status: BookingStatus.PENDING_CONFIRMATION,
cancelledAt: null,
confirmedAt: null,
@@ -682,6 +781,100 @@ describe('BookingService', () => {
expect(result.refunded).toBe(true)
})
it('restores a count-limited DURATION membership when cancelled within the limit', async () => {
const limitedDurationMembership = {
...mockLimitedDurationMembership,
remainingTimes: 4,
}
const bookingWithRelations = {
...mockConfirmedBooking,
membershipId: limitedDurationMembership.id,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: limitedDurationMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: limitedDurationMembership.id,
status: BookingStatus.CANCELLED,
})
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
tx.membership.update.mockResolvedValue({
...limitedDurationMembership,
remainingTimes: 5,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: limitedDurationMembership.id },
data: expect.objectContaining({
remainingTimes: 5,
status: MembershipStatus.ACTIVE,
}),
}),
)
expect(result.refunded).toBe(true)
})
it('does not restore an unlimited DURATION membership when cancelled within the limit', async () => {
const bookingWithRelations = {
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
membershipTimesDeducted: false,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: mockDurationMembership,
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockDurationMembership.id,
status: BookingStatus.CANCELLED,
})
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)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
})
it('does not refund an originally unlimited membership after it is changed to counted', async () => {
const bookingWithRelations = {
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
membershipTimesDeducted: false,
timeSlot: { ...futureSlot, bookedCount: 1 },
membership: {
...mockLimitedDurationMembership,
remainingTimes: 10,
},
}
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
const tx = buildTxMock()
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
membershipId: mockLimitedDurationMembership.id,
membershipTimesDeducted: false,
status: BookingStatus.CANCELLED,
})
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)
expect(tx.membership.update).not.toHaveBeenCalled()
expect(result.refunded).toBe(false)
})
it('cancels booking past limit: does NOT refund membership', async () => {
const bookingWithImminent = {
...mockConfirmedBooking,
@@ -1034,7 +1227,10 @@ describe('BookingService', () => {
function stubArrangeSuccess(
tx: ReturnType<typeof buildTxMock>,
options?: {
membership?: typeof mockActiveMembership | typeof mockDurationMembership | typeof mockTrialMembership
membership?: typeof mockActiveMembership
| typeof mockDurationMembership
| typeof mockLimitedDurationMembership
| typeof mockTrialMembership
slot?: typeof mockOpenSlot
existing?: typeof mockConfirmedBooking | null
},
@@ -1093,6 +1289,7 @@ describe('BookingService', () => {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
membershipTimesDeducted: true,
status: BookingStatus.CONFIRMED,
operatorId: MOCK_ADMIN_ID,
}),
@@ -1127,7 +1324,7 @@ describe('BookingService', () => {
expect(result.status).toBe(BookingStatus.CONFIRMED)
})
it('does not deduct remaining times for duration cards', async () => {
it('does not deduct remaining times for unlimited duration cards', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockDurationMembership })
@@ -1137,7 +1334,33 @@ describe('BookingService', () => {
})
expect(tx.membership.update).not.toHaveBeenCalled()
expect(tx.booking.create).toHaveBeenCalled()
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: false }),
}),
)
})
it('deducts remaining times for count-limited duration cards', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockLimitedDurationMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockLimitedDurationMembership.id,
})
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: mockLimitedDurationMembership.id },
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
}),
)
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ membershipTimesDeducted: true }),
}),
)
})
it('deducts a trial card session', async () => {