feat(server): add membership and time-slot modules

Membership: card type CRUD, deduction/restore logic, valid card lookup (15 tests)
TimeSlot: slot generation from week templates, availability query with booking
status, admin management, cleanup tasks (26 tests)
65 total tests passing
This commit is contained in:
richarjiang
2026-04-02 12:24:07 +08:00
parent a1a91f96d8
commit 593a6e5453
16 changed files with 1746 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
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,
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,
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,
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,
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(),
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 not change times for a DURATION card', 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 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 throw NotFoundException when membership does not exist', async () => {
mockPrismaService.membership.findUnique.mockResolvedValue(null)
await expect(service.restoreMembership('non-existent')).rejects.toThrow(NotFoundException)
})
})
// ─── 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 }),
}),
)
})
})
})