feat: 支持同卡种续卡叠加并修复过期登录态
支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ const mockTimesCardType = {
|
||||
price: 150000,
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
@@ -31,6 +32,7 @@ const mockDurationCardType = {
|
||||
price: 80000,
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
@@ -48,6 +50,7 @@ const mockActiveMembership = {
|
||||
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,
|
||||
@@ -61,6 +64,7 @@ const mockDurationMembership = {
|
||||
id: 'mem-duration-001',
|
||||
cardTypeId: mockDurationCardType.id,
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
cardType: mockDurationCardType,
|
||||
}
|
||||
|
||||
@@ -77,6 +81,7 @@ const mockPrismaService = {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}
|
||||
@@ -300,6 +305,160 @@ describe('MembershipService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 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()', () => {
|
||||
|
||||
Reference in New Issue
Block a user