Implement backend API and database services in Docker setup
- 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>
This commit is contained in:
514
backend/src/subscriptions/tests/subscriptions.service.spec.ts
Normal file
514
backend/src/subscriptions/tests/subscriptions.service.spec.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
/* 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user