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:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

File diff suppressed because it is too large Load Diff

View 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');
});
});
});