/* eslint-disable unicorn/no-null */ import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from 'src/users/users.service'; import { MailService } from 'src/mail/mail.service'; import { Repository } from 'typeorm'; import { getRepositoryToken } from '@nestjs/typeorm'; import Stripe from 'stripe'; import { Subscription } from '../entities/subscription.entity'; import { SubscriptionsService } from '../subscriptions.service'; import { CreateSubscriptionDTO } from '../dto/create-subscription.dto'; import { audienceMonthlyRejectedStripe, audienceMonthlyStripe, rssAddonMonthlyStripe, verificationAddonMonthlyStripe, } from '../../../test/mocks/subscription'; import { audienceUserNoSubscription, audienceUserStripe, proPlusUserStripe, ultimateUserStripe, } from '../../../test/mocks/user'; import { Logger } from '@nestjs/common'; jest.mock('stripe'); // Mock Stripe module jest.mock('@nestjs/common/services/logger.service'); describe('SubscriptionsService', () => { let service: SubscriptionsService; let usersService: UsersService; let mailService: MailService; let subscriptionsRepository: Repository; const stripe: jest.Mocked = { checkout: { sessions: { create: jest.fn() } }, billingPortal: { sessions: { create: jest.fn() }, configurations: { create: jest.fn() }, }, subscriptions: { retrieve: jest.fn(), }, } as unknown as jest.Mocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SubscriptionsService, { provide: UsersService, useValue: { findUserByEmail: jest.fn(), }, }, { provide: MailService, useValue: { sendMail: jest.fn() }, }, { provide: getRepositoryToken(Subscription), useValue: { update: jest.fn(), save: jest.fn(), findOne: jest.fn(), findOneOrFail: jest.fn(), delete: jest.fn(), }, }, ], }).compile(); service = module.get(SubscriptionsService); service['stripe'] = stripe; usersService = module.get(UsersService); mailService = module.get(MailService); subscriptionsRepository = module.get>( getRepositoryToken(Subscription), ); }); it('should be defined', () => { expect(service).toBeDefined(); }); describe('createCheckout', () => { // Mock Stripe's session creation method it('should create a checkout session', async () => { const mockDTO: CreateSubscriptionDTO = { type: 'enthusiast', period: 'monthly', }; const mockSession: any = { url: 'https://stripe.checkout.url' }; jest .spyOn(stripe.checkout.sessions, 'create') .mockImplementation() .mockResolvedValue(mockSession); jest.spyOn(stripe.subscriptions, 'retrieve').mockResolvedValue({ items: { data: [ { price: { id: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID }, }, ], }, } as any); const result = await service.createCheckout( mockDTO, audienceUserNoSubscription, ); expect(result).toBe(mockSession.url); expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({ mode: 'subscription', allow_promotion_codes: true, line_items: [ { price: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID, quantity: 1 }, ], subscription_data: { trial_period_days: 14 }, customer_email: audienceUserNoSubscription.email, payment_method_types: ['card'], success_url: expect.stringContaining('?refresh=true'), cancel_url: expect.stringContaining('/subscription'), }); }); }); describe('updateSubscription', () => { it('should update a subscription on webhook event', async () => { const mockWebhookEvent = { data: { object: { id: 'sub_123', object: 'subscription', cancel_at: null }, }, }; jest .spyOn(service, 'getByStripeId') .mockResolvedValue(audienceMonthlyStripe); jest.spyOn(subscriptionsRepository, 'update').mockResolvedValue({ affected: 1, raw: [], generatedMaps: [], }); await service.updateSubscription(mockWebhookEvent as any); expect(subscriptionsRepository.update).toHaveBeenCalledWith( { id: audienceMonthlyStripe.id }, { type: expect.any(String), period: expect.any(String), status: 'succeeded', }, ); }); }); describe('notifySubscriptor', () => { it('should send an email notification to the subscriber', async () => { jest .spyOn(service, 'getByStripeId') .mockResolvedValue(audienceMonthlyStripe); jest.spyOn(mailService, 'sendMail').mockResolvedValue(); await service.notifySubscriptor( audienceMonthlyStripe.stripeId, 'd-62fb0e9d71304edfaed4f4f1781dc6f9', { trial_end: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60, }, ); expect(mailService.sendMail).toHaveBeenCalledWith({ to: audienceMonthlyStripe.user.email, templateId: 'd-62fb0e9d71304edfaed4f4f1781dc6f9', data: { trial_end: expect.any(Number), }, }); }); }); describe('getByStripeId', () => { it('should return a subscription by Stripe ID', async () => { jest .spyOn(subscriptionsRepository, 'findOneOrFail') .mockResolvedValue(audienceMonthlyStripe); const result = await service.getByStripeId(audienceMonthlyStripe.id); expect(result).toBe(audienceMonthlyStripe); expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({ where: { stripeId: audienceMonthlyStripe.id }, relations: ['user'], }); }); }); describe('getPriceId', () => { it('should return the correct price ID', () => { const type = 'enthusiast'; const period = 'monthly'; const result = service['getPriceId'](type, period); expect(result).toBe(process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID); }); }); describe('getTypePeriodFromPriceId', () => { it('should return the type and period from the price ID', () => { const mockPriceId = process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID; const result = service['getTypePeriodFromPriceId'](mockPriceId); expect(result).toStrictEqual({ type: 'enthusiast', period: 'monthly', }); }); }); describe('updateOnCheckoutSessionCompleted', () => { it('should update subscription on checkout session completed', async () => { const mockWebhookEvent: any = { data: { object: { subscription: rssAddonMonthlyStripe.stripeId, payment_status: 'paid', }, }, }; const mockStripeSubscription = { subscription: { id: rssAddonMonthlyStripe.stripeId, items: { data: [ { price: { id: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID }, }, ], }, customer: { email: proPlusUserStripe.email }, current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }; jest .spyOn(service, 'getStripeSubscription') .mockResolvedValue(mockStripeSubscription as any); jest .spyOn(usersService, 'findUserByEmail') .mockResolvedValue(proPlusUserStripe); jest.spyOn(subscriptionsRepository, 'findOne').mockResolvedValue(null); jest .spyOn(subscriptionsRepository, 'save') .mockResolvedValue(rssAddonMonthlyStripe); await service.updateOnCheckoutSessionCompleted(mockWebhookEvent); expect(service.getStripeSubscription).toHaveBeenCalledWith('sub_123'); expect(usersService.findUserByEmail).toHaveBeenCalledWith( proPlusUserStripe.email, ); expect(subscriptionsRepository.save).toHaveBeenCalled(); }); it('should notify subscriber if subscription type is audience', async () => { const mockWebhookEvent: any = { data: { object: { subscription: audienceMonthlyStripe.stripeId, payment_status: 'paid', }, }, }; const mockStripeSubscription = { subscription: { id: audienceMonthlyStripe.stripeId, items: { data: [ { price: { id: process.env.STRIPE_AUDIENCE_MONTHLY_PRICE_ID }, }, ], }, customer: { email: audienceUserStripe.email }, current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }; jest .spyOn(service, 'getStripeSubscription') .mockResolvedValue(mockStripeSubscription as any); jest .spyOn(usersService, 'findUserByEmail') .mockResolvedValue(audienceUserStripe); jest .spyOn(subscriptionsRepository, 'findOne') .mockResolvedValue(audienceMonthlyStripe); jest.spyOn(service, 'notifySubscriptor').mockResolvedValue(); await service.updateOnCheckoutSessionCompleted(mockWebhookEvent); expect(service.getStripeSubscription).toHaveBeenCalledWith( audienceMonthlyStripe.stripeId, ); expect(usersService.findUserByEmail).toHaveBeenCalledWith( audienceUserStripe.email, ); expect(service.notifySubscriptor).toHaveBeenCalledWith( audienceMonthlyStripe.stripeId, 'd-62fb0e9d71304edfaed4f4f1781dc6f9', ); }); }); describe('updateOnInvoicePaymentFailed', () => { it('should update subscription status to rejected on invoice payment failed', async () => { const mockWebhookEvent: any = { data: { object: { subscription: audienceMonthlyRejectedStripe.stripeId, }, }, }; const mockStripeSubscription = { subscription: { id: audienceMonthlyRejectedStripe.stripeId, current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }; jest .spyOn(service, 'getStripeSubscription') .mockResolvedValue(mockStripeSubscription as any); jest .spyOn(subscriptionsRepository, 'findOneOrFail') .mockResolvedValue(audienceMonthlyRejectedStripe); jest .spyOn(subscriptionsRepository, 'save') .mockResolvedValue(audienceMonthlyRejectedStripe); await service.updateOnInvoicePaymentFailed(mockWebhookEvent); expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({ where: { stripeId: audienceMonthlyRejectedStripe.stripeId }, }); expect(subscriptionsRepository.save).toHaveBeenCalledWith({ ...audienceMonthlyRejectedStripe, status: 'rejected', periodEnd: new Date( mockStripeSubscription.subscription.current_period_end * 1000, ), }); }); it('should log an error if subscription is not found on invoice payment failed', async () => { const mockWebhookEvent: any = { data: { object: { subscription: 'non_existent_subscription_id', }, }, }; jest .spyOn(subscriptionsRepository, 'findOneOrFail') .mockRejectedValue(new Error('No subscription found')); await service.updateOnInvoicePaymentFailed(mockWebhookEvent); expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({ where: { stripeId: 'non_existent_subscription_id' }, }); expect(Logger.error).toHaveBeenCalledTimes(2); }); }); describe('createBilling', () => { it('should create a billing portal session', async () => { const mockStripeSubscription = { subscription: { id: rssAddonMonthlyStripe.stripeId, customer: { id: 'cus_123', email: proPlusUserStripe.email }, }, }; const mockSession = { url: 'https://billing.portal.url' }; jest .spyOn(subscriptionsRepository, 'findOneOrFail') .mockResolvedValue(rssAddonMonthlyStripe); jest .spyOn(service, 'getStripeSubscription') .mockResolvedValue(mockStripeSubscription as any); jest .spyOn(stripe.billingPortal.configurations, 'create') .mockResolvedValue({ id: 'config_123', } as any); jest .spyOn(stripe.billingPortal.sessions, 'create') .mockImplementation() .mockResolvedValue(mockSession as any); const result = await service.createBilling(proPlusUserStripe, 'pro-plus'); expect(result).toBe(mockSession.url); expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({ where: { userId: proPlusUserStripe.id }, order: { createdAt: 'DESC' }, }); expect(service.getStripeSubscription).toHaveBeenCalledWith( rssAddonMonthlyStripe.stripeId, ); expect( service['stripe'].billingPortal.sessions.create, ).toHaveBeenCalledWith({ customer: 'cus_123', return_url: expect.stringContaining('?refresh=true'), configuration: expect.any(String), }); }); it('should delete the subscription and throw an error if creating billing portal fails', async () => { jest .spyOn(subscriptionsRepository, 'findOneOrFail') .mockResolvedValue(verificationAddonMonthlyStripe); jest .spyOn(service, 'getStripeSubscription') .mockRejectedValue(new Error('Stripe error')); jest.spyOn(subscriptionsRepository, 'delete').mockResolvedValue({ affected: 1, raw: [], }); await expect( service.createBilling(ultimateUserStripe, 'ultimate'), ).rejects.toThrow('Error creating billing portal'); expect(subscriptionsRepository.findOneOrFail).toHaveBeenCalledWith({ where: { userId: ultimateUserStripe.id }, order: { createdAt: 'DESC' }, }); expect(service.getStripeSubscription).toHaveBeenCalledWith( verificationAddonMonthlyStripe.stripeId, ); expect(subscriptionsRepository.delete).toHaveBeenCalledWith( verificationAddonMonthlyStripe.id, ); }); }); describe('updateOnInvoicePaid', () => { it('should update subscription status to succeeded on invoice paid', async () => { const mockWebhookEvent: any = { data: { object: { subscription: audienceMonthlyStripe.stripeId, }, }, }; const mockStripeSubscription = { subscription: { id: audienceMonthlyStripe.stripeId, current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, }, }; jest .spyOn(service, 'getStripeSubscription') .mockResolvedValue(mockStripeSubscription as any); jest .spyOn(subscriptionsRepository, 'update') .mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }); await service.updateOnInvoicePaid(mockWebhookEvent); expect(subscriptionsRepository.update).toHaveBeenCalledWith( { stripeId: audienceMonthlyStripe.stripeId }, { status: 'succeeded', periodEnd: new Date( mockStripeSubscription.subscription.current_period_end * 1000 + 24 * 60 * 60 * 1000, ), }, ); }); it('should log an error if subscription is not found on invoice paid', async () => { const mockWebhookEvent: any = { data: { object: { subscription: 'non_existent_subscription_id', }, }, }; jest .spyOn(service, 'getStripeSubscription') .mockRejectedValue(new Error('No subscription found')); await service.updateOnInvoicePaid(mockWebhookEvent); expect(Logger.error).toHaveBeenCalledWith('No subscription found'); }); }); });