578 lines
20 KiB
TypeScript
578 lines
20 KiB
TypeScript
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>(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<MembershipService['grantPurchasedCard']>[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 }),
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
})
|