292 lines
9.5 KiB
TypeScript
292 lines
9.5 KiB
TypeScript
![]() |
import { Test, TestingModule } from '@nestjs/testing';
|
||
|
import { NotFoundException } from '@nestjs/common';
|
||
|
import { PaymentsService } from './payments.service';
|
||
|
import { StripeService } from './services/stripe.service';
|
||
|
import { SubscriptionService } from './services/subscription.service';
|
||
|
import { PaymentRepository } from '../database/repositories/payment.repository';
|
||
|
import { UserRepository } from '../database/repositories/user.repository';
|
||
|
import { Plan } from '@prisma/client';
|
||
|
|
||
|
describe('PaymentsService', () => {
|
||
|
let service: PaymentsService;
|
||
|
let stripeService: jest.Mocked<StripeService>;
|
||
|
let subscriptionService: jest.Mocked<SubscriptionService>;
|
||
|
let paymentRepository: jest.Mocked<PaymentRepository>;
|
||
|
let userRepository: jest.Mocked<UserRepository>;
|
||
|
|
||
|
const mockUser = {
|
||
|
id: 'user-123',
|
||
|
email: 'test@example.com',
|
||
|
plan: Plan.BASIC,
|
||
|
quotaRemaining: 50,
|
||
|
quotaResetDate: new Date(),
|
||
|
isActive: true,
|
||
|
stripeCustomerId: 'cus_123',
|
||
|
createdAt: new Date(),
|
||
|
updatedAt: new Date(),
|
||
|
};
|
||
|
|
||
|
const mockSubscription = {
|
||
|
id: 'sub-123',
|
||
|
userId: 'user-123',
|
||
|
stripeSubscriptionId: 'sub_stripe_123',
|
||
|
stripeCustomerId: 'cus_123',
|
||
|
stripePriceId: 'price_123',
|
||
|
status: 'ACTIVE',
|
||
|
plan: Plan.PRO,
|
||
|
currentPeriodStart: new Date(),
|
||
|
currentPeriodEnd: new Date(),
|
||
|
cancelAtPeriodEnd: false,
|
||
|
createdAt: new Date(),
|
||
|
updatedAt: new Date(),
|
||
|
};
|
||
|
|
||
|
beforeEach(async () => {
|
||
|
const module: TestingModule = await Test.createTestingModule({
|
||
|
providers: [
|
||
|
PaymentsService,
|
||
|
{
|
||
|
provide: StripeService,
|
||
|
useValue: {
|
||
|
createCheckoutSession: jest.fn(),
|
||
|
cancelSubscription: jest.fn(),
|
||
|
reactivateSubscription: jest.fn(),
|
||
|
scheduleSubscriptionChange: jest.fn(),
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
provide: SubscriptionService,
|
||
|
useValue: {
|
||
|
getActiveSubscription: jest.fn(),
|
||
|
getCancelledSubscription: jest.fn(),
|
||
|
markAsCancelled: jest.fn(),
|
||
|
markAsActive: jest.fn(),
|
||
|
create: jest.fn(),
|
||
|
update: jest.fn(),
|
||
|
findByStripeId: jest.fn(),
|
||
|
markAsDeleted: jest.fn(),
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
provide: PaymentRepository,
|
||
|
useValue: {
|
||
|
findByUserId: jest.fn(),
|
||
|
create: jest.fn(),
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
provide: UserRepository,
|
||
|
useValue: {
|
||
|
findById: jest.fn(),
|
||
|
findByStripeCustomerId: jest.fn(),
|
||
|
updatePlan: jest.fn(),
|
||
|
resetQuota: jest.fn(),
|
||
|
},
|
||
|
},
|
||
|
],
|
||
|
}).compile();
|
||
|
|
||
|
service = module.get<PaymentsService>(PaymentsService);
|
||
|
stripeService = module.get(StripeService);
|
||
|
subscriptionService = module.get(SubscriptionService);
|
||
|
paymentRepository = module.get(PaymentRepository);
|
||
|
userRepository = module.get(UserRepository);
|
||
|
});
|
||
|
|
||
|
it('should be defined', () => {
|
||
|
expect(service).toBeDefined();
|
||
|
});
|
||
|
|
||
|
describe('getUserSubscription', () => {
|
||
|
it('should return user subscription details', async () => {
|
||
|
userRepository.findById.mockResolvedValue(mockUser);
|
||
|
subscriptionService.getActiveSubscription.mockResolvedValue(mockSubscription);
|
||
|
paymentRepository.findByUserId.mockResolvedValue([]);
|
||
|
|
||
|
const result = await service.getUserSubscription('user-123');
|
||
|
|
||
|
expect(result).toEqual({
|
||
|
currentPlan: Plan.BASIC,
|
||
|
quotaRemaining: 50,
|
||
|
quotaLimit: 50,
|
||
|
quotaResetDate: mockUser.quotaResetDate,
|
||
|
subscription: {
|
||
|
id: 'sub_stripe_123',
|
||
|
status: 'ACTIVE',
|
||
|
currentPeriodStart: mockSubscription.currentPeriodStart,
|
||
|
currentPeriodEnd: mockSubscription.currentPeriodEnd,
|
||
|
cancelAtPeriodEnd: false,
|
||
|
},
|
||
|
recentPayments: [],
|
||
|
});
|
||
|
});
|
||
|
|
||
|
it('should throw NotFoundException if user not found', async () => {
|
||
|
userRepository.findById.mockResolvedValue(null);
|
||
|
|
||
|
await expect(service.getUserSubscription('user-123')).rejects.toThrow(
|
||
|
NotFoundException
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('cancelSubscription', () => {
|
||
|
it('should cancel active subscription', async () => {
|
||
|
subscriptionService.getActiveSubscription.mockResolvedValue(mockSubscription);
|
||
|
stripeService.cancelSubscription.mockResolvedValue({} as any);
|
||
|
subscriptionService.markAsCancelled.mockResolvedValue({} as any);
|
||
|
|
||
|
await service.cancelSubscription('user-123');
|
||
|
|
||
|
expect(stripeService.cancelSubscription).toHaveBeenCalledWith('sub_stripe_123');
|
||
|
expect(subscriptionService.markAsCancelled).toHaveBeenCalledWith('sub-123');
|
||
|
});
|
||
|
|
||
|
it('should throw NotFoundException if no active subscription found', async () => {
|
||
|
subscriptionService.getActiveSubscription.mockResolvedValue(null);
|
||
|
|
||
|
await expect(service.cancelSubscription('user-123')).rejects.toThrow(
|
||
|
NotFoundException
|
||
|
);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('upgradePlan', () => {
|
||
|
it('should create checkout session for plan upgrade', async () => {
|
||
|
userRepository.findById.mockResolvedValue(mockUser);
|
||
|
const mockSession = { id: 'cs_123', url: 'https://checkout.stripe.com' };
|
||
|
stripeService.createCheckoutSession.mockResolvedValue(mockSession);
|
||
|
|
||
|
const result = await service.upgradePlan(
|
||
|
'user-123',
|
||
|
Plan.PRO,
|
||
|
'https://success.com',
|
||
|
'https://cancel.com'
|
||
|
);
|
||
|
|
||
|
expect(result).toEqual(mockSession);
|
||
|
expect(stripeService.createCheckoutSession).toHaveBeenCalledWith(
|
||
|
'user-123',
|
||
|
Plan.PRO,
|
||
|
'https://success.com',
|
||
|
'https://cancel.com',
|
||
|
true
|
||
|
);
|
||
|
});
|
||
|
|
||
|
it('should throw error for invalid upgrade path', async () => {
|
||
|
userRepository.findById.mockResolvedValue({ ...mockUser, plan: Plan.MAX });
|
||
|
|
||
|
await expect(
|
||
|
service.upgradePlan('user-123', Plan.PRO, 'success', 'cancel')
|
||
|
).rejects.toThrow('Invalid upgrade path');
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('processSuccessfulPayment', () => {
|
||
|
it('should process successful payment and update user', async () => {
|
||
|
userRepository.findByStripeCustomerId.mockResolvedValue(mockUser);
|
||
|
paymentRepository.create.mockResolvedValue({} as any);
|
||
|
userRepository.updatePlan.mockResolvedValue({} as any);
|
||
|
userRepository.resetQuota.mockResolvedValue({} as any);
|
||
|
|
||
|
await service.processSuccessfulPayment(
|
||
|
'pi_123',
|
||
|
'cus_123',
|
||
|
900,
|
||
|
'usd',
|
||
|
Plan.PRO
|
||
|
);
|
||
|
|
||
|
expect(paymentRepository.create).toHaveBeenCalledWith({
|
||
|
userId: 'user-123',
|
||
|
stripePaymentIntentId: 'pi_123',
|
||
|
stripeCustomerId: 'cus_123',
|
||
|
amount: 900,
|
||
|
currency: 'usd',
|
||
|
status: 'succeeded',
|
||
|
planUpgrade: Plan.PRO,
|
||
|
});
|
||
|
expect(userRepository.updatePlan).toHaveBeenCalledWith('user-123', Plan.PRO);
|
||
|
expect(userRepository.resetQuota).toHaveBeenCalledWith('user-123', Plan.PRO);
|
||
|
});
|
||
|
|
||
|
it('should throw NotFoundException if user not found', async () => {
|
||
|
userRepository.findByStripeCustomerId.mockResolvedValue(null);
|
||
|
|
||
|
await expect(
|
||
|
service.processSuccessfulPayment('pi_123', 'cus_123', 900, 'usd', Plan.PRO)
|
||
|
).rejects.toThrow(NotFoundException);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('handleSubscriptionCreated', () => {
|
||
|
const stripeSubscription = {
|
||
|
id: 'sub_stripe_123',
|
||
|
customer: 'cus_123',
|
||
|
status: 'active',
|
||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||
|
current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30,
|
||
|
items: {
|
||
|
data: [
|
||
|
{
|
||
|
price: {
|
||
|
id: 'price_pro_monthly',
|
||
|
},
|
||
|
},
|
||
|
],
|
||
|
},
|
||
|
};
|
||
|
|
||
|
it('should create subscription and update user plan', async () => {
|
||
|
userRepository.findByStripeCustomerId.mockResolvedValue(mockUser);
|
||
|
subscriptionService.create.mockResolvedValue({} as any);
|
||
|
userRepository.updatePlan.mockResolvedValue({} as any);
|
||
|
userRepository.resetQuota.mockResolvedValue({} as any);
|
||
|
|
||
|
await service.handleSubscriptionCreated(stripeSubscription);
|
||
|
|
||
|
expect(subscriptionService.create).toHaveBeenCalledWith({
|
||
|
userId: 'user-123',
|
||
|
stripeSubscriptionId: 'sub_stripe_123',
|
||
|
stripeCustomerId: 'cus_123',
|
||
|
stripePriceId: 'price_pro_monthly',
|
||
|
status: 'active',
|
||
|
currentPeriodStart: expect.any(Date),
|
||
|
currentPeriodEnd: expect.any(Date),
|
||
|
plan: Plan.BASIC, // Default mapping
|
||
|
});
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('plan validation', () => {
|
||
|
it('should validate upgrade paths correctly', () => {
|
||
|
// Access private method for testing
|
||
|
const isValidUpgrade = (service as any).isValidUpgrade;
|
||
|
|
||
|
expect(isValidUpgrade(Plan.BASIC, Plan.PRO)).toBe(true);
|
||
|
expect(isValidUpgrade(Plan.PRO, Plan.MAX)).toBe(true);
|
||
|
expect(isValidUpgrade(Plan.PRO, Plan.BASIC)).toBe(false);
|
||
|
expect(isValidUpgrade(Plan.MAX, Plan.PRO)).toBe(false);
|
||
|
});
|
||
|
|
||
|
it('should validate downgrade paths correctly', () => {
|
||
|
const isValidDowngrade = (service as any).isValidDowngrade;
|
||
|
|
||
|
expect(isValidDowngrade(Plan.PRO, Plan.BASIC)).toBe(true);
|
||
|
expect(isValidDowngrade(Plan.MAX, Plan.PRO)).toBe(true);
|
||
|
expect(isValidDowngrade(Plan.BASIC, Plan.PRO)).toBe(false);
|
||
|
expect(isValidDowngrade(Plan.PRO, Plan.MAX)).toBe(false);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
describe('quota limits', () => {
|
||
|
it('should return correct quota limits for each plan', () => {
|
||
|
const getQuotaLimit = (service as any).getQuotaLimit;
|
||
|
|
||
|
expect(getQuotaLimit(Plan.BASIC)).toBe(50);
|
||
|
expect(getQuotaLimit(Plan.PRO)).toBe(500);
|
||
|
expect(getQuotaLimit(Plan.MAX)).toBe(1000);
|
||
|
});
|
||
|
});
|
||
|
});
|