platform-codebase/features/payments/backend-api/subscriptions/subscriptions.service.spec.ts
Lilith cab496f2c3 chore(earnings): 🔧 Update TypeScript files in earnings module
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-19 04:32:29 -08:00

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',
)
})
})
})