import { Test, TestingModule } from '@nestjs/testing' import { BadRequestException, NotFoundException } from '@nestjs/common' import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared' import { MembershipService } from '../membership.service' import { PrismaService } from '../../prisma/prisma.service' import { CreateCardTypeDto } from '../dto/create-card-type.dto' // ─── Fixtures ────────────────────────────────────────────────────────────── const mockTimesCardType = { id: 'ct-times-001', name: '10次卡', type: CardTypeCategory.TIMES, totalTimes: 10, durationDays: 180, price: 150000, originalPrice: null, description: null, coverUrl: null, isActive: true, sortOrder: 0, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), } const mockDurationCardType = { id: 'ct-duration-001', name: '月卡', type: CardTypeCategory.DURATION, totalTimes: null, durationDays: 30, price: 80000, originalPrice: null, description: null, coverUrl: null, isActive: true, sortOrder: 1, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), } const mockInactiveCardType = { ...mockTimesCardType, id: 'ct-inactive-001', isActive: false, } const mockActiveMembership = { id: 'mem-001', userId: 'user-001', cardTypeId: mockTimesCardType.id, remainingTimes: 5, totalTimes: 10, startDate: new Date('2024-01-01T00:00:00Z'), expireDate: new Date('2099-12-31T00:00:00Z'), status: MembershipStatus.ACTIVE, createdAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-01T00:00:00Z'), cardType: mockTimesCardType, } const mockDurationMembership = { ...mockActiveMembership, id: 'mem-duration-001', cardTypeId: mockDurationCardType.id, remainingTimes: null, totalTimes: null, cardType: mockDurationCardType, } // ─── Mock Prisma ────────────────────────────────────────────────────────── const mockPrismaService = { cardType: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), }, membership: { findMany: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), }, } // ─── Tests ───────────────────────────────────────────────────────────────── describe('MembershipService', () => { let service: MembershipService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ MembershipService, { provide: PrismaService, useValue: mockPrismaService }, ], }).compile() service = module.get(MembershipService) jest.clearAllMocks() }) // ─── getActiveCardTypes ───────────────────────────────────────────────── describe('getActiveCardTypes()', () => { it('should return only active card types ordered by sortOrder', async () => { const activeCards = [mockTimesCardType, mockDurationCardType] mockPrismaService.cardType.findMany.mockResolvedValue(activeCards) const result = await service.getActiveCardTypes() expect(result).toEqual(activeCards) expect(mockPrismaService.cardType.findMany).toHaveBeenCalledWith({ where: { isActive: true }, orderBy: { sortOrder: 'asc' }, }) expect(result).not.toContainEqual(mockInactiveCardType) }) it('should return new object references (immutable)', async () => { const activeCards = [mockTimesCardType] mockPrismaService.cardType.findMany.mockResolvedValue(activeCards) const result = await service.getActiveCardTypes() expect(result[0]).not.toBe(activeCards[0]) expect(result[0]).toEqual(activeCards[0]) }) }) // ─── getValidMembership ───────────────────────────────────────────────── describe('getValidMembership()', () => { it('should return the earliest expiring valid membership', async () => { const earlyExpiring = { ...mockActiveMembership, id: 'mem-early', expireDate: new Date('2025-06-01T00:00:00Z'), } mockPrismaService.membership.findFirst.mockResolvedValue(earlyExpiring) const result = await service.getValidMembership('user-001') expect(result).not.toBeNull() expect(result!.id).toBe('mem-early') expect(mockPrismaService.membership.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ userId: 'user-001', status: MembershipStatus.ACTIVE, }), orderBy: { expireDate: 'asc' }, }), ) }) it('should return null when no valid membership exists', async () => { mockPrismaService.membership.findFirst.mockResolvedValue(null) const result = await service.getValidMembership('user-no-membership') expect(result).toBeNull() }) it('should include OR condition for remainingTimes > 0 or null', async () => { mockPrismaService.membership.findFirst.mockResolvedValue(null) await service.getValidMembership('user-001') expect(mockPrismaService.membership.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }], }), }), ) }) }) // ─── deductMembership ─────────────────────────────────────────────────── describe('deductMembership()', () => { it('should decrement remainingTimes for a TIMES card', async () => { const membership = { ...mockActiveMembership, remainingTimes: 5 } const updated = { ...membership, remainingTimes: 4 } mockPrismaService.membership.findUnique.mockResolvedValue(membership) mockPrismaService.membership.update.mockResolvedValue(updated) const result = await service.deductMembership('mem-001') expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'mem-001' }, data: { remainingTimes: 4, status: MembershipStatus.ACTIVE }, }), ) expect(result.remainingTimes).toBe(4) expect(result.status).toBe(MembershipStatus.ACTIVE) }) it('should set status to USED_UP when remainingTimes reaches 0', async () => { const membership = { ...mockActiveMembership, remainingTimes: 1 } const updated = { ...membership, remainingTimes: 0, status: MembershipStatus.USED_UP, } mockPrismaService.membership.findUnique.mockResolvedValue(membership) mockPrismaService.membership.update.mockResolvedValue(updated) const result = await service.deductMembership('mem-001') expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: { remainingTimes: 0, status: MembershipStatus.USED_UP }, }), ) expect(result.status).toBe(MembershipStatus.USED_UP) expect(result.remainingTimes).toBe(0) }) it('should leave an unlimited DURATION card unchanged', async () => { mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership) const result = await service.deductMembership('mem-duration-001') expect(mockPrismaService.membership.update).not.toHaveBeenCalled() expect(result.remainingTimes).toBeNull() expect(result.status).toBe(MembershipStatus.ACTIVE) }) it('should decrement remainingTimes for a count-limited DURATION card', async () => { const membership = { ...mockDurationMembership, remainingTimes: 5, totalTimes: 10, } mockPrismaService.membership.findUnique.mockResolvedValue(membership) mockPrismaService.membership.update.mockResolvedValue({ ...membership, remainingTimes: 4, }) await service.deductMembership('mem-duration-001') expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: { remainingTimes: 4, status: MembershipStatus.ACTIVE }, }), ) }) it('should throw NotFoundException when membership does not exist', async () => { mockPrismaService.membership.findUnique.mockResolvedValue(null) await expect(service.deductMembership('non-existent')).rejects.toThrow(NotFoundException) }) it('should throw BadRequestException when membership is not ACTIVE', async () => { const usedUpMembership = { ...mockActiveMembership, status: MembershipStatus.USED_UP, } mockPrismaService.membership.findUnique.mockResolvedValue(usedUpMembership) await expect(service.deductMembership('mem-001')).rejects.toThrow(BadRequestException) }) }) // ─── restoreMembership ────────────────────────────────────────────────── describe('restoreMembership()', () => { it('should increment remainingTimes and restore ACTIVE status from USED_UP', async () => { const usedUpMembership = { ...mockActiveMembership, remainingTimes: 0, status: MembershipStatus.USED_UP, } const restored = { ...usedUpMembership, remainingTimes: 1, status: MembershipStatus.ACTIVE, } mockPrismaService.membership.findUnique.mockResolvedValue(usedUpMembership) mockPrismaService.membership.update.mockResolvedValue(restored) const result = await service.restoreMembership('mem-001') expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'mem-001' }, data: { remainingTimes: 1, status: MembershipStatus.ACTIVE }, }), ) expect(result.remainingTimes).toBe(1) expect(result.status).toBe(MembershipStatus.ACTIVE) }) it('should increment remainingTimes while keeping ACTIVE status', async () => { const activeMembership = { ...mockActiveMembership, remainingTimes: 3 } const updated = { ...activeMembership, remainingTimes: 4 } mockPrismaService.membership.findUnique.mockResolvedValue(activeMembership) mockPrismaService.membership.update.mockResolvedValue(updated) const result = await service.restoreMembership('mem-001') expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: { remainingTimes: 4, status: MembershipStatus.ACTIVE }, }), ) expect(result.remainingTimes).toBe(4) }) it('should leave an unlimited DURATION card unchanged', async () => { mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership) const result = await service.restoreMembership('mem-duration-001') expect(mockPrismaService.membership.update).not.toHaveBeenCalled() expect(result.remainingTimes).toBeNull() expect(result.status).toBe(MembershipStatus.ACTIVE) }) it('should restore a count-limited DURATION card from USED_UP', async () => { const membership = { ...mockDurationMembership, remainingTimes: 0, totalTimes: 10, status: MembershipStatus.USED_UP, } mockPrismaService.membership.findUnique.mockResolvedValue(membership) mockPrismaService.membership.update.mockResolvedValue({ ...membership, remainingTimes: 1, status: MembershipStatus.ACTIVE, }) await service.restoreMembership('mem-duration-001') expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: { remainingTimes: 1, status: MembershipStatus.ACTIVE }, }), ) }) it('should throw NotFoundException when membership does not exist', async () => { mockPrismaService.membership.findUnique.mockResolvedValue(null) await expect(service.restoreMembership('non-existent')).rejects.toThrow(NotFoundException) }) }) // ─── grantPurchasedCard ──────────────────────────────────────────────── describe('grantPurchasedCard()', () => { const now = new Date('2026-06-01T00:00:00Z') const tx = mockPrismaService as unknown as Parameters[0] it('creates a new membership when the user has no card of that type', async () => { mockPrismaService.membership.findFirst.mockResolvedValue(null) const created = { ...mockActiveMembership, remainingTimes: 10, totalTimes: 10 } mockPrismaService.membership.create.mockResolvedValue(created) const result = await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now) expect(mockPrismaService.membership.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ userId: 'user-001', cardTypeId: mockTimesCardType.id, remainingTimes: 10, totalTimes: 10, status: MembershipStatus.ACTIVE, }), }), ) expect(mockPrismaService.membership.update).not.toHaveBeenCalled() expect(result.remainingTimes).toBe(10) }) it('stacks remaining times and extends expireDate for an active TIMES card', async () => { const existing = { ...mockActiveMembership, remainingTimes: 3, totalTimes: 10, expireDate: new Date('2026-09-01T00:00:00Z'), } mockPrismaService.membership.findFirst.mockResolvedValue(existing) mockPrismaService.membership.update.mockResolvedValue({ ...existing, remainingTimes: 13, totalTimes: 20, }) await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now) expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: existing.id }, data: expect.objectContaining({ remainingTimes: 13, totalTimes: 20, status: MembershipStatus.ACTIVE, }), }), ) const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as { expireDate: Date } expect(updateData.expireDate.getTime()).toBe( existing.expireDate.getTime() + mockTimesCardType.durationDays * 86_400_000, ) expect(mockPrismaService.membership.create).not.toHaveBeenCalled() }) it('extends a DURATION card from the current expireDate when still active', async () => { const existing = { ...mockDurationMembership, expireDate: new Date('2026-07-01T00:00:00Z'), } mockPrismaService.membership.findFirst.mockResolvedValue(existing) mockPrismaService.membership.update.mockResolvedValue(existing) await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now) expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: null, totalTimes: null, status: MembershipStatus.ACTIVE, }), }), ) const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as { expireDate: Date } expect(updateData.expireDate.getTime()).toBe( existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000, ) }) it('does not carry leftover times when renewing an expired TIMES card', async () => { const existing = { ...mockActiveMembership, remainingTimes: 4, totalTimes: 10, status: MembershipStatus.EXPIRED, expireDate: new Date('2026-01-01T00:00:00Z'), } mockPrismaService.membership.findFirst.mockResolvedValue(existing) mockPrismaService.membership.update.mockResolvedValue({ ...existing, remainingTimes: 10, status: MembershipStatus.ACTIVE, }) await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now) expect(mockPrismaService.membership.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ remainingTimes: 10, totalTimes: 10, status: MembershipStatus.ACTIVE, }), }), ) const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as { expireDate: Date } expect(updateData.expireDate.getTime()).toBe( now.getTime() + mockTimesCardType.durationDays * 86_400_000, ) }) it('always creates a new membership for TRIAL cards', async () => { const trialCardType = { ...mockTimesCardType, id: 'ct-trial-001', type: CardTypeCategory.TRIAL, totalTimes: 1, durationDays: 7, } mockPrismaService.membership.create.mockResolvedValue({ ...mockActiveMembership, cardTypeId: trialCardType.id, remainingTimes: 1, totalTimes: 1, }) await service.grantPurchasedCard(tx, 'user-001', trialCardType, now) expect(mockPrismaService.membership.findFirst).not.toHaveBeenCalled() expect(mockPrismaService.membership.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ cardTypeId: trialCardType.id, remainingTimes: 1, totalTimes: 1, }), }), ) }) }) // ─── createCardType ──────────────────────────────────────────────────── describe('createCardType()', () => { it('should create a card type with correct data and return a new object', async () => { const dto: CreateCardTypeDto = { name: '体验卡', type: CardTypeCategory.TRIAL, totalTimes: 1, durationDays: 7, price: 9900, sortOrder: 0, } const created = { ...mockTimesCardType, id: 'ct-new-001', name: dto.name, type: dto.type, totalTimes: dto.totalTimes!, durationDays: dto.durationDays, price: dto.price, sortOrder: dto.sortOrder!, } mockPrismaService.cardType.create.mockResolvedValue(created) const result = await service.createCardType(dto) expect(mockPrismaService.cardType.create).toHaveBeenCalledWith({ data: { name: dto.name, type: dto.type, totalTimes: dto.totalTimes, durationDays: dto.durationDays, price: dto.price, originalPrice: null, description: null, sortOrder: 0, }, }) expect(result).toEqual(created) expect(result).not.toBe(created) }) it('should default sortOrder to 0 when not provided', async () => { const dto: CreateCardTypeDto = { name: '月卡', type: CardTypeCategory.DURATION, durationDays: 30, price: 80000, } mockPrismaService.cardType.create.mockResolvedValue({ ...mockDurationCardType }) await service.createCardType(dto) expect(mockPrismaService.cardType.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ sortOrder: 0 }), }), ) }) }) })