- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO. - Introduced PostgreSQL and Redis services with health checks and configurations for data persistence. - Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets. - Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage. - Enhanced the Dockerfile to support the new API environment variables and configurations. - Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities. Co-authored-by: Cursor <cursoragent@cursor.com>
515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
/* 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<Subscription>;
|
|
const stripe: jest.Mocked<Stripe> = {
|
|
checkout: { sessions: { create: jest.fn() } },
|
|
billingPortal: {
|
|
sessions: { create: jest.fn() },
|
|
configurations: { create: jest.fn() },
|
|
},
|
|
subscriptions: {
|
|
retrieve: jest.fn(),
|
|
},
|
|
} as unknown as jest.Mocked<Stripe>;
|
|
|
|
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>(SubscriptionsService);
|
|
service['stripe'] = stripe;
|
|
usersService = module.get<UsersService>(UsersService);
|
|
mailService = module.get<MailService>(MailService);
|
|
subscriptionsRepository = module.get<Repository<Subscription>>(
|
|
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');
|
|
});
|
|
});
|
|
});
|