618 lines
24 KiB
TypeScript
618 lines
24 KiB
TypeScript
/**
|
|
* Unit Tests for SubscriptionsService
|
|
*
|
|
* Comprehensive test coverage for subscription lifecycle:
|
|
* creation, cancellation, tier changes, 3DS completion, and sync.
|
|
*/
|
|
|
|
import { NotFoundException, BadRequestException } from '@nestjs/common'
|
|
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|
|
|
import { SubscriptionsService } from './subscriptions.service'
|
|
import { SubscriptionEntity } from '../src/entities/subscription.entity'
|
|
import { SubscriptionStatus } from '../../providers/subscription.types'
|
|
import { PaymentProvider } from '../../providers/common.types'
|
|
import { createMockRepository, createMockDomainEventsEmitter, type MockRepository } from '../test/mocks'
|
|
|
|
describe('SubscriptionsService', () => {
|
|
let service: SubscriptionsService
|
|
let subscriptionRepository: MockRepository<SubscriptionEntity>
|
|
let providerFactory: any
|
|
let domainEvents: any
|
|
let mockProvider: any
|
|
|
|
const now = new Date('2026-02-01T00:00:00Z')
|
|
|
|
const mockSubscriptionEntity: Partial<SubscriptionEntity> = {
|
|
id: 'sub-123',
|
|
userId: 'user-001',
|
|
creatorId: 'creator-001',
|
|
tierId: 'tier-001',
|
|
status: SubscriptionStatus.ACTIVE,
|
|
priceCents: 999,
|
|
currency: 'USD',
|
|
interval: 'monthly',
|
|
currentPeriodStart: new Date('2026-02-01T00:00:00Z'),
|
|
currentPeriodEnd: new Date('2026-03-01T00:00:00Z'),
|
|
cancelAtPeriodEnd: false,
|
|
provider: PaymentProvider.SEGPAY,
|
|
providerSubscriptionId: 'prov-sub-456',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
({ repository: subscriptionRepository } = createMockRepository<SubscriptionEntity>())
|
|
domainEvents = createMockDomainEventsEmitter()
|
|
|
|
mockProvider = {
|
|
createSubscription: vi.fn(),
|
|
getSubscription: vi.fn(),
|
|
cancelSubscription: vi.fn(),
|
|
}
|
|
|
|
providerFactory = {
|
|
getProvider: vi.fn().mockReturnValue(mockProvider),
|
|
getProviderById: vi.fn().mockReturnValue(mockProvider),
|
|
}
|
|
|
|
service = new SubscriptionsService(
|
|
subscriptionRepository,
|
|
providerFactory as any,
|
|
domainEvents as any,
|
|
)
|
|
})
|
|
|
|
// ============================================================================
|
|
// create()
|
|
// ============================================================================
|
|
|
|
describe('create', () => {
|
|
const createRequest = {
|
|
userId: 'user-001',
|
|
creatorId: 'creator-001',
|
|
tierId: 'tier-001',
|
|
tierPriceCents: 999,
|
|
paymentMethod: 'card' as const,
|
|
customerEmail: 'user@example.com',
|
|
}
|
|
|
|
it('should create a subscription via the correct provider with computed price', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'active',
|
|
nextBillingDate: new Date('2026-03-01'),
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
await service.create(createRequest)
|
|
|
|
expect(providerFactory.getProvider).toHaveBeenCalledWith('card')
|
|
expect(mockProvider.createSubscription).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-001',
|
|
tierId: 'tier-001',
|
|
tierPriceUsd: 9.99,
|
|
customerEmail: 'user@example.com',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('should save entity with ACTIVE status when provider returns active', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'active',
|
|
nextBillingDate: new Date('2026-03-01'),
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
const result = await service.create(createRequest)
|
|
|
|
expect(subscriptionRepository.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-001',
|
|
creatorId: 'creator-001',
|
|
tierId: 'tier-001',
|
|
status: SubscriptionStatus.ACTIVE,
|
|
priceCents: 999,
|
|
provider: PaymentProvider.SEGPAY,
|
|
providerSubscriptionId: 'prov-sub-456',
|
|
}),
|
|
)
|
|
expect(result.subscriptionId).toBe('sub-123')
|
|
expect(result.status).toBe('active')
|
|
expect(result.requires3ds).toBe(false)
|
|
})
|
|
|
|
it('should save entity with PENDING status when provider returns pending', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'pending_3ds',
|
|
clientSecret: 'secret-abc',
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.PENDING,
|
|
} as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
id: 'sub-123',
|
|
status: SubscriptionStatus.PENDING,
|
|
} as SubscriptionEntity)
|
|
|
|
const result = await service.create(createRequest)
|
|
|
|
expect(result.requires3ds).toBe(true)
|
|
expect(result.clientSecret).toBe('secret-abc')
|
|
})
|
|
|
|
it('should emit SUBSCRIPTION_CREATED domain event when active', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'active',
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
await service.create(createRequest)
|
|
|
|
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
subscriptionId: 'sub-123',
|
|
userId: 'user-001',
|
|
tierId: 'tier-001',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('should NOT emit domain event when status is not active', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'pending_3ds',
|
|
clientSecret: 'secret-abc',
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.PENDING,
|
|
} as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.PENDING,
|
|
} as SubscriptionEntity)
|
|
|
|
await service.create(createRequest)
|
|
|
|
expect(domainEvents.emitSubscriptionCreated).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should throw BadRequestException when userId is missing', async () => {
|
|
await expect(
|
|
service.create({ ...createRequest, userId: '' }),
|
|
).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should throw BadRequestException when creatorId is missing', async () => {
|
|
await expect(
|
|
service.create({ ...createRequest, creatorId: '' }),
|
|
).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should throw BadRequestException when tierId is missing', async () => {
|
|
await expect(
|
|
service.create({ ...createRequest, tierId: '' }),
|
|
).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should throw BadRequestException when tierPriceCents is 0', async () => {
|
|
await expect(
|
|
service.create({ ...createRequest, tierPriceCents: 0 }),
|
|
).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should throw BadRequestException when tierPriceCents is negative', async () => {
|
|
await expect(
|
|
service.create({ ...createRequest, tierPriceCents: -100 }),
|
|
).rejects.toThrow(BadRequestException)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// createWithPayment()
|
|
// ============================================================================
|
|
|
|
describe('createWithPayment', () => {
|
|
const paymentRequest = {
|
|
userId: 'user-001',
|
|
creatorId: 'creator-001',
|
|
tierId: 'tier-001',
|
|
tierPriceCents: 999,
|
|
cardNumber: '4111111111111111',
|
|
expiryMonth: '12',
|
|
expiryYear: '2027',
|
|
cvv: '123',
|
|
cardholderName: 'Test User',
|
|
customerEmail: 'user@example.com',
|
|
}
|
|
|
|
it('should pass card details to the card provider', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'active',
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
await service.createWithPayment(paymentRequest)
|
|
|
|
expect(providerFactory.getProvider).toHaveBeenCalledWith('card')
|
|
expect(mockProvider.createSubscription).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tierPriceUsd: 9.99,
|
|
cardDetails: expect.objectContaining({
|
|
cardNumber: '4111111111111111',
|
|
expiryMonth: '12',
|
|
expiryYear: '2027',
|
|
cvv: '123',
|
|
cardholderName: 'Test User',
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('should convert tierPriceCents to USD for the provider', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'active',
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
await service.createWithPayment({ ...paymentRequest, tierPriceCents: 2500 })
|
|
|
|
expect(mockProvider.createSubscription).toHaveBeenCalledWith(
|
|
expect.objectContaining({ tierPriceUsd: 25 }),
|
|
)
|
|
})
|
|
|
|
it('should throw BadRequestException when tierPriceCents is 0', async () => {
|
|
await expect(
|
|
service.createWithPayment({ ...paymentRequest, tierPriceCents: 0 }),
|
|
).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should throw BadRequestException when tierPriceCents is negative', async () => {
|
|
await expect(
|
|
service.createWithPayment({ ...paymentRequest, tierPriceCents: -100 }),
|
|
).rejects.toThrow(BadRequestException)
|
|
})
|
|
|
|
it('should save entity with SEGPAY provider', async () => {
|
|
vi.mocked(mockProvider.createSubscription).mockResolvedValue({
|
|
subscriptionId: 'prov-sub-456',
|
|
status: 'active',
|
|
})
|
|
vi.mocked(subscriptionRepository.create).mockReturnValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
await service.createWithPayment(paymentRequest)
|
|
|
|
expect(subscriptionRepository.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
provider: PaymentProvider.SEGPAY,
|
|
priceCents: 999,
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// getById()
|
|
// ============================================================================
|
|
|
|
describe('getById', () => {
|
|
it('should return subscription entity when found', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
const result = await service.getById('sub-123')
|
|
|
|
expect(subscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: 'sub-123' } })
|
|
expect(result.id).toBe('sub-123')
|
|
})
|
|
|
|
it('should throw NotFoundException when subscription not found', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue(null)
|
|
|
|
await expect(service.getById('nonexistent')).rejects.toThrow(NotFoundException)
|
|
await expect(service.getById('nonexistent')).rejects.toThrow('Subscription nonexistent not found')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// listByUser()
|
|
// ============================================================================
|
|
|
|
describe('listByUser', () => {
|
|
it('should query by userId with DESC order', async () => {
|
|
vi.mocked(subscriptionRepository.find).mockResolvedValue([mockSubscriptionEntity as SubscriptionEntity])
|
|
|
|
const result = await service.listByUser('user-001')
|
|
|
|
expect(subscriptionRepository.find).toHaveBeenCalledWith({
|
|
where: { userId: 'user-001' },
|
|
order: { createdAt: 'DESC' },
|
|
})
|
|
expect(result).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// cancel()
|
|
// ============================================================================
|
|
|
|
describe('cancel', () => {
|
|
it('should cancel with provider and set cancelAtPeriodEnd=true by default', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
const result = await service.cancel('sub-123')
|
|
|
|
expect(providerFactory.getProviderById).toHaveBeenCalledWith('segpay')
|
|
expect(mockProvider.cancelSubscription).toHaveBeenCalledWith('prov-sub-456', true)
|
|
expect(result.cancelAtPeriodEnd).toBe(true)
|
|
})
|
|
|
|
it('should set status to CANCELLED when cancelAtPeriodEnd is false', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
} as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
const result = await service.cancel('sub-123', false)
|
|
|
|
expect(result.status).toBe(SubscriptionStatus.CANCELLED)
|
|
expect(result.cancelledAt).toBeInstanceOf(Date)
|
|
})
|
|
|
|
it('should throw BadRequestException when already cancelled', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.CANCELLED,
|
|
} as SubscriptionEntity)
|
|
|
|
await expect(service.cancel('sub-123')).rejects.toThrow(BadRequestException)
|
|
await expect(service.cancel('sub-123')).rejects.toThrow('Subscription is already cancelled')
|
|
})
|
|
|
|
it('should emit SUBSCRIPTION_CANCELLED domain event', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
await service.cancel('sub-123')
|
|
|
|
expect(domainEvents.emitSubscriptionCancelled).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
subscriptionId: 'sub-123',
|
|
userId: 'user-001',
|
|
tierId: 'tier-001',
|
|
reason: 'customer_request',
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('should skip provider cancellation when no providerSubscriptionId', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
providerSubscriptionId: undefined,
|
|
} as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
await service.cancel('sub-123')
|
|
|
|
expect(providerFactory.getProviderById).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// complete3DS()
|
|
// ============================================================================
|
|
|
|
describe('complete3DS', () => {
|
|
it('should activate subscription when provider confirms active', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.PENDING,
|
|
} as SubscriptionEntity)
|
|
vi.mocked(mockProvider.getSubscription).mockResolvedValue({
|
|
status: 'active',
|
|
currentPeriodStart: new Date('2026-02-01'),
|
|
currentPeriodEnd: new Date('2026-03-01'),
|
|
})
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
const result = await service.complete3DS('sub-123')
|
|
|
|
expect(result.status).toBe(SubscriptionStatus.ACTIVE)
|
|
expect(domainEvents.emitSubscriptionCreated).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should throw BadRequestException when subscription is not PENDING', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue(mockSubscriptionEntity as SubscriptionEntity)
|
|
|
|
await expect(service.complete3DS('sub-123')).rejects.toThrow(BadRequestException)
|
|
await expect(service.complete3DS('sub-123')).rejects.toThrow(
|
|
'Subscription is not pending 3DS authentication',
|
|
)
|
|
})
|
|
|
|
it('should not emit event if provider status is not active', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.PENDING,
|
|
} as SubscriptionEntity)
|
|
vi.mocked(mockProvider.getSubscription).mockResolvedValue({
|
|
status: 'pending',
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: new Date(),
|
|
})
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
await service.complete3DS('sub-123')
|
|
|
|
expect(domainEvents.emitSubscriptionCreated).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// sync()
|
|
// ============================================================================
|
|
|
|
describe('sync', () => {
|
|
it('should update entity status from provider details', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
vi.mocked(mockProvider.getSubscription).mockResolvedValue({
|
|
status: 'past_due',
|
|
currentPeriodStart: new Date('2026-02-01'),
|
|
currentPeriodEnd: new Date('2026-03-01'),
|
|
cancelAtPeriodEnd: false,
|
|
})
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
const result = await service.sync('sub-123')
|
|
|
|
expect(result.status).toBe(SubscriptionStatus.PAST_DUE)
|
|
})
|
|
|
|
it('should throw BadRequestException when no providerSubscriptionId', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
providerSubscriptionId: undefined,
|
|
} as SubscriptionEntity)
|
|
|
|
await expect(service.sync('sub-123')).rejects.toThrow(BadRequestException)
|
|
await expect(service.sync('sub-123')).rejects.toThrow('Subscription has no provider subscription ID')
|
|
})
|
|
|
|
it('should map cancelled status from provider', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
vi.mocked(mockProvider.getSubscription).mockResolvedValue({
|
|
status: 'cancelled',
|
|
currentPeriodStart: new Date(),
|
|
currentPeriodEnd: new Date(),
|
|
cancelAtPeriodEnd: false,
|
|
})
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
const result = await service.sync('sub-123')
|
|
|
|
expect(result.status).toBe(SubscriptionStatus.CANCELLED)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// changeTier()
|
|
// ============================================================================
|
|
|
|
describe('changeTier', () => {
|
|
it('should schedule tier change on active subscription', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
const result = await service.changeTier('sub-123', 'tier-002')
|
|
|
|
expect(result.scheduledTierChangeId).toBe('tier-002')
|
|
})
|
|
|
|
it('should throw BadRequestException when subscription is not active', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.CANCELLED,
|
|
} as SubscriptionEntity)
|
|
|
|
await expect(service.changeTier('sub-123', 'tier-002')).rejects.toThrow(BadRequestException)
|
|
await expect(service.changeTier('sub-123', 'tier-002')).rejects.toThrow(
|
|
'Can only change tier on active subscriptions',
|
|
)
|
|
})
|
|
|
|
it('should throw BadRequestException when new tier is the same', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
|
|
await expect(service.changeTier('sub-123', 'tier-001')).rejects.toThrow(BadRequestException)
|
|
await expect(service.changeTier('sub-123', 'tier-001')).rejects.toThrow(
|
|
'New tier is the same as the current tier',
|
|
)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// getTierChangePreview()
|
|
// ============================================================================
|
|
|
|
describe('getTierChangePreview', () => {
|
|
it('should return prorated amounts and dates', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
|
|
const result = await service.getTierChangePreview('sub-123', 'tier-002')
|
|
|
|
expect(result.currentTier.id).toBe('tier-001')
|
|
expect(result.newTier.id).toBe('tier-002')
|
|
expect(typeof result.proratedAmount).toBe('number')
|
|
expect(result.effectiveDate).toBe(mockSubscriptionEntity.currentPeriodEnd!.toISOString())
|
|
})
|
|
|
|
it('should populate newTier from newTierData when provided', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({ ...mockSubscriptionEntity } as SubscriptionEntity)
|
|
|
|
const result = await service.getTierChangePreview('sub-123', 'tier-002', {
|
|
name: 'Premium',
|
|
priceCents: 1999,
|
|
features: ['feature-a', 'feature-b'],
|
|
})
|
|
|
|
expect(result.newTier.name).toBe('Premium')
|
|
expect(result.newTier.price).toBe(19.99)
|
|
expect(result.newTier.features).toEqual(['feature-a', 'feature-b'])
|
|
})
|
|
|
|
it('should throw BadRequestException when subscription is not active', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
status: SubscriptionStatus.EXPIRED,
|
|
} as SubscriptionEntity)
|
|
|
|
await expect(service.getTierChangePreview('sub-123', 'tier-002')).rejects.toThrow(BadRequestException)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// cancelTierChange()
|
|
// ============================================================================
|
|
|
|
describe('cancelTierChange', () => {
|
|
it('should clear scheduledTierChangeId', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
scheduledTierChangeId: 'tier-002',
|
|
} as SubscriptionEntity)
|
|
vi.mocked(subscriptionRepository.save).mockImplementation(async (entity: any) => entity)
|
|
|
|
const result = await service.cancelTierChange('sub-123')
|
|
|
|
expect(result.scheduledTierChangeId).toBeUndefined()
|
|
})
|
|
|
|
it('should throw BadRequestException when no tier change is scheduled', async () => {
|
|
vi.mocked(subscriptionRepository.findOne).mockResolvedValue({
|
|
...mockSubscriptionEntity,
|
|
scheduledTierChangeId: undefined,
|
|
} as SubscriptionEntity)
|
|
|
|
await expect(service.cancelTierChange('sub-123')).rejects.toThrow(BadRequestException)
|
|
await expect(service.cancelTierChange('sub-123')).rejects.toThrow(
|
|
'No tier change is scheduled for this subscription',
|
|
)
|
|
})
|
|
})
|
|
})
|