From 90ce1a76167a1d397b61f5b05a2675a7820ec93e Mon Sep 17 00:00:00 2001 From: Daniel Nigusse Date: Sun, 25 Aug 2024 02:24:45 +0300 Subject: [PATCH] feat: add db transaction interceptor and made it atomic --- app/src/app.module.ts | 20 +- app/src/controllers/auth.controller.ts | 2 +- app/src/controllers/core.controller.ts | 196 ------------ app/src/controllers/data-lookup.controller.ts | 78 +++++ .../subscription-plan.controller.ts | 96 ++++++ .../controllers/subscription.controller.ts | 240 ++++----------- .../controllers/system-setting.controller.ts | 155 ++++++++++ app/src/controllers/webhooks.controller.ts | 8 +- app/src/entities/base.entity.ts | 14 + .../interceptors/transaction.interceptor.ts | 40 +++ app/src/main.ts | 8 + app/src/services/base.service.ts | 92 ++---- app/src/services/data-lookup.service.ts | 59 +++- app/src/services/setting.service.ts | 36 ++- app/src/services/subscription-plan.service.ts | 112 +++++++ app/src/services/subscription.service.ts | 190 ++---------- ...spec.ts => data-lookup.controller.spec.ts} | 29 +- .../controllers/settings.controller.spec.ts | 37 ++- .../subscription-plan.controller.spec.ts | 104 +++++++ .../subscription.controller.spec.ts | 115 ++----- app/tests/services/base.service.spec.ts | 79 +---- .../services/data-lookup.service.spec.ts | 93 +++--- app/tests/services/setting.service.spec.ts | 75 +++-- .../subscription-plan.service.spec.ts | 201 ++++++++++++ .../services/subscription.service.spec.ts | 291 +++++------------- 25 files changed, 1283 insertions(+), 1087 deletions(-) delete mode 100644 app/src/controllers/core.controller.ts create mode 100644 app/src/controllers/data-lookup.controller.ts create mode 100644 app/src/controllers/subscription-plan.controller.ts create mode 100644 app/src/controllers/system-setting.controller.ts create mode 100644 app/src/interceptors/transaction.interceptor.ts create mode 100644 app/src/services/subscription-plan.service.ts rename app/tests/controllers/{lookup.controller.spec.ts => data-lookup.controller.spec.ts} (76%) create mode 100644 app/tests/controllers/subscription-plan.controller.spec.ts create mode 100644 app/tests/services/subscription-plan.service.spec.ts diff --git a/app/src/app.module.ts b/app/src/app.module.ts index f697900..09dcd24 100644 --- a/app/src/app.module.ts +++ b/app/src/app.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; import { dataSrouceOptions } from '../db/data-source'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { SubscriptionController } from './controllers/subscription.controller'; -import { SubscriptionService } from './services/subscription.service'; +import { SubscriptionPlanController } from './controllers/subscription-plan.controller'; +import { SubscriptionPlanService } from './services/subscription-plan.service'; import { SubscriptionPlan } from './entities/subscription.entity'; import { CustomerSubscription } from './entities/customer.entity'; import { Invoice } from './entities/invoice.entity'; @@ -21,9 +21,8 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { User } from './entities/user.entity'; import { ScheduleModule } from '@nestjs/schedule'; import { - SystemSettingController, DataLookupController, -} from './controllers/core.controller'; +} from './controllers/data-lookup.controller'; import { DataLookupService } from './services/data-lookup.service'; import { SystemSettingService } from './services/setting.service'; import { SystemSetting } from './entities/system-settings.entity'; @@ -35,6 +34,10 @@ import { PaymentMethod } from './entities/payment-method.entity'; import { StripeService } from './services/stripe.service'; import { BillingService } from './services/billing.service'; import { NotificationsService } from './services/notifications.service'; +import { CustomerSubscriptionService } from './services/subscription.service'; +import { CustomerSubscriptionController } from './controllers/subscription.controller'; +import { SystemSettingController } from './controllers/system-setting.controller'; +import { WebhooksController } from './controllers/webhooks.controller'; const config = new ConfigService(); @Module({ @@ -78,13 +81,16 @@ const config = new ConfigService(); ), ], controllers: [ - SubscriptionController, + SubscriptionPlanController, + CustomerSubscriptionController, AuthController, SystemSettingController, DataLookupController, + WebhooksController, ], providers: [ - SubscriptionService, + SubscriptionPlanService, + CustomerSubscriptionService, AuthService, StripeService, NotificationsService, @@ -100,5 +106,5 @@ const config = new ConfigService(); ], }) export class AppModule { - constructor(private dataSource: DataSource) {} + constructor(private dataSource: DataSource) { } } diff --git a/app/src/controllers/auth.controller.ts b/app/src/controllers/auth.controller.ts index 1af377c..b8f613d 100644 --- a/app/src/controllers/auth.controller.ts +++ b/app/src/controllers/auth.controller.ts @@ -16,7 +16,7 @@ import { ConfigService } from '@nestjs/config'; /** * Authentication controller that handles user registration, login, and profile retrieval. */ -@ApiTags('auth') +@ApiTags('Authentication') @Controller({ path: 'auth', version: new ConfigService().get('API_VERSION') }) export class AuthController { constructor(private readonly authService: AuthService) {} diff --git a/app/src/controllers/core.controller.ts b/app/src/controllers/core.controller.ts deleted file mode 100644 index e9b75d0..0000000 --- a/app/src/controllers/core.controller.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - Controller, - Post, - Body, - Get, - Param, - Patch, - Delete, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { - CreateSystemSettingDto, - ResetSystemSettingDto, - UpdateSystemSettingDto, -} from '../dtos/settings.dto'; -import { SystemSetting } from '../entities/system-settings.entity'; -import { SystemSettingService } from '../services/setting.service'; -import { CreateDataLookupDto } from '../dtos/core.dto'; -import { DataLookupService } from '../services/data-lookup.service'; -import { ConfigService } from '@nestjs/config'; - -const config = new ConfigService(); - -/** - * Controller for managing system settings. - */ -@ApiTags('Core') -@Controller({ path: 'core/settings', version: config.get('API_VERSION') }) -export class SystemSettingController { - constructor(private readonly systemSettingService: SystemSettingService) {} - - /** - * Creates a new system setting. - * - * @param createSystemSettingDto - DTO containing data to create a new system setting. - * @returns The newly created SystemSetting entity. - */ - @Post() - @ApiOperation({ summary: 'Create a new system setting' }) - @ApiResponse({ - status: 201, - description: 'The setting has been successfully created.', - type: SystemSetting, - }) - async create( - @Body() createSystemSettingDto: CreateSystemSettingDto, - ): Promise { - return await this.systemSettingService.create(createSystemSettingDto); - } - - /** - * Retrieves all system settings. - * - * @returns An array of SystemSetting entities. - */ - @Get() - @ApiOperation({ summary: 'Retrieve all system settings' }) - @ApiResponse({ - status: 200, - description: 'Array of settings retrieved.', - type: [SystemSetting], - }) - async findAll(): Promise { - return await this.systemSettingService.findAll(); - } - - /** - * Retrieves a single system setting by ID. - * - * @param id - The ID of the system setting to retrieve. - * @returns The found SystemSetting entity. - */ - @Get(':id') - @ApiOperation({ summary: 'Retrieve a single system setting by ID' }) - @ApiResponse({ - status: 200, - description: 'System setting retrieved.', - type: SystemSetting, - }) - async findOne(@Param('id') id: string): Promise { - return await this.systemSettingService.findOne(id); - } - - /** - * Updates a system setting by ID. - * - * @param id - The ID of the system setting to update. - * @param updateSystemSettingDto - DTO containing the updated data for the system setting. - * @returns The updated SystemSetting entity. - */ - @Patch(':id') - @ApiOperation({ summary: 'Update a system setting by ID' }) - @ApiResponse({ - status: 200, - description: 'The setting has been successfully updated.', - type: SystemSetting, - }) - async update( - @Param('id') id: string, - @Body() updateSystemSettingDto: UpdateSystemSettingDto, - ): Promise { - return await this.systemSettingService.update(id, updateSystemSettingDto); - } - - /** - * Deletes a system setting by ID. - * - * @param id - The ID of the system setting to delete. - */ - @Delete(':id') - @ApiOperation({ summary: 'Delete a system setting by ID' }) - @ApiResponse({ - status: 200, - description: 'The setting has been successfully deleted.', - }) - async remove(@Param('id') id: string): Promise { - await this.systemSettingService.remove(id); - } - - /** - * Resets a system setting to its default value by code. - * - * @param resetSystemSettingDto - DTO containing the code of the system setting to reset. - * @returns The reset SystemSetting entity. - */ - @Patch('reset') - @ApiOperation({ summary: 'Reset a system setting by code' }) - @ApiResponse({ - status: 200, - description: 'The setting has been reset to its default value.', - type: SystemSetting, - }) - async resetSetting( - @Body() resetSystemSettingDto: ResetSystemSettingDto, - ): Promise { - return await this.systemSettingService.resetSetting( - resetSystemSettingDto.code, - ); - } -} - -/** - * Controller for managing lookup data. - */ -@ApiTags('Core') -@Controller({ path: 'core/lookup-data', version: config.get('API_VERSION') }) -export class DataLookupController { - constructor(private readonly dataLookupService: DataLookupService) {} - - /** - * Creates a new data lookup entry. - * - * @param createDataLookupDto - DTO containing the data to create a new data lookup entry. - * @returns The newly created DataLookup entity. - */ - @Post() - @ApiOperation({ summary: 'Create a new data lookup entry' }) - async create(@Body() createDataLookupDto: CreateDataLookupDto) { - return this.dataLookupService.create(createDataLookupDto); - } - - /** - * Creates multiple data lookup entries in bulk. - * - * @param createDataLookupDtos - Array of DTOs containing the data to create multiple data lookup entries. - * @returns An array of created DataLookup entities. - */ - @Post('bulk') - @ApiOperation({ summary: 'Create multiple data lookup entries in bulk' }) - async createBulk(@Body() createDataLookupDtos: CreateDataLookupDto[]) { - return this.dataLookupService.createBulk(createDataLookupDtos); - } - - /** - * Retrieves all data lookup entries. - * - * @returns An array of DataLookup entities. - */ - @Get() - @ApiOperation({ summary: 'Retrieve all data lookup entries' }) - async findAll() { - return this.dataLookupService.findAll(); - } - - /** - * Retrieves a single data lookup entry by ID. - * - * @param id - The ID of the data lookup entry to retrieve. - * @returns The found DataLookup entity. - */ - @Get(':id') - @ApiOperation({ summary: 'Retrieve a single data lookup entry by ID' }) - async findOne(@Param('id') id: string) { - return this.dataLookupService.findOne(id); - } -} diff --git a/app/src/controllers/data-lookup.controller.ts b/app/src/controllers/data-lookup.controller.ts new file mode 100644 index 0000000..7960275 --- /dev/null +++ b/app/src/controllers/data-lookup.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Post, + Body, + Get, + Param, + Req, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { CreateDataLookupDto } from '../dtos/core.dto'; +import { DataLookupService } from '../services/data-lookup.service'; +import { ConfigService } from '@nestjs/config'; + +const config = new ConfigService(); + +/** + * Controller for managing lookup data. + */ +@ApiTags('Configurations') +@Controller({ path: 'core/lookup-data', version: config.get('API_VERSION') }) +export class DataLookupController { + constructor(private readonly dataLookupService: DataLookupService) { } + + /** + * Creates a new data lookup entry. + * + * @param createDataLookupDto - DTO containing the data to create a new data lookup entry. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The newly created DataLookup entity. + */ + @Post() + @ApiOperation({ summary: 'Create a new data lookup entry' }) + async create(@Body() createDataLookupDto: CreateDataLookupDto, @Req() req: any) { + const entityManager = req.transactionManager; + return this.dataLookupService.create(createDataLookupDto, entityManager); + } + + /** + * Creates multiple data lookup entries in bulk. + * + * @param createDataLookupDtos - Array of DTOs containing the data to create multiple data lookup entries. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns An array of created DataLookup entities. + */ + @Post('bulk') + @ApiOperation({ summary: 'Create multiple data lookup entries in bulk' }) + async createBulk(@Body() createDataLookupDtos: CreateDataLookupDto[], @Req() req: any) { + const entityManager = req.transactionManager; + return this.dataLookupService.createBulk(createDataLookupDtos, entityManager); + } + + /** + * Retrieves all data lookup entries. + * + * @param req - The HTTP request object, which contains the transaction manager. + * @returns An array of DataLookup entities. + */ + @Get() + @ApiOperation({ summary: 'Retrieve all data lookup entries' }) + async findAll(@Req() req: any) { + const entityManager = req.transactionManager; + return this.dataLookupService.findAll(entityManager); + } + + /** + * Retrieves a single data lookup entry by ID. + * + * @param id - The ID of the data lookup entry to retrieve. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The found DataLookup entity. + */ + @Get(':id') + @ApiOperation({ summary: 'Retrieve a single data lookup entry by ID' }) + async findOne(@Param('id') id: string, @Req() req: any) { + const entityManager = req.transactionManager; + return this.dataLookupService.findOne(id, entityManager); + } +} diff --git a/app/src/controllers/subscription-plan.controller.ts b/app/src/controllers/subscription-plan.controller.ts new file mode 100644 index 0000000..93ec53e --- /dev/null +++ b/app/src/controllers/subscription-plan.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Post, + Get, + Param, + Delete, + Patch, + Body, + Req, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { SubscriptionPlanService } from '../services/subscription-plan.service'; +import { SubscriptionPlan } from '../entities/subscription.entity'; +import { + CreateSubscriptionPlanDto, + UpdateSubscriptionPlanDto, +} from '../dtos/subscription.dto'; +import { ConfigService } from '@nestjs/config'; + +const config = new ConfigService(); + +@ApiTags('Subscription-plans') +@Controller({ path: 'subscription-plans', version: config.get('API_VERSION') }) +export class SubscriptionPlanController { + constructor( + private readonly subscriptionPlanService: SubscriptionPlanService, + ) { } + + @Post() + @ApiOperation({ summary: 'Create a new subscription plan' }) + @ApiResponse({ + status: 201, + description: 'The subscription plan has been successfully created.', + type: SubscriptionPlan, + }) + createSubscriptionPlan( + @Body() createSubscriptionPlanDto: CreateSubscriptionPlanDto, + @Req() req: any, + ): Promise { + return this.subscriptionPlanService.createSubscriptionPlan( + createSubscriptionPlanDto, + req.transactionManager, + ); + } + + @Get() + @ApiOperation({ summary: 'Get all active subscription plans' }) + @ApiResponse({ + status: 200, + description: 'List of subscription plans.', + type: [SubscriptionPlan], + }) + getSubscriptionPlans(@Req() req: any): Promise { + return this.subscriptionPlanService.getSubscriptionPlans(req.transactionManager); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a subscription plan by ID' }) + @ApiResponse({ + status: 200, + description: 'The subscription plan details.', + type: SubscriptionPlan, + }) + getSubscriptionPlanById(@Param('id') id: string, @Req() req: any): Promise { + return this.subscriptionPlanService.getSubscriptionPlanById(id, req.transactionManager); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a subscription plan by ID' }) + @ApiResponse({ + status: 200, + description: 'The subscription plan has been updated.', + type: SubscriptionPlan, + }) + updateSubscriptionPlan( + @Param('id') id: string, + @Body() updateSubscriptionPlanDto: UpdateSubscriptionPlanDto, + @Req() req: any, + ): Promise { + return this.subscriptionPlanService.updateSubscriptionPlan( + id, + updateSubscriptionPlanDto, + req.transactionManager, + ); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a subscription plan by ID' }) + @ApiResponse({ + status: 204, + description: 'The subscription plan has been deleted.', + }) + deleteSubscriptionPlan(@Param('id') id: string, @Req() req: any): Promise { + return this.subscriptionPlanService.deleteSubscriptionPlan(id, req.transactionManager); + } +} diff --git a/app/src/controllers/subscription.controller.ts b/app/src/controllers/subscription.controller.ts index f39954d..c9ee556 100644 --- a/app/src/controllers/subscription.controller.ts +++ b/app/src/controllers/subscription.controller.ts @@ -1,188 +1,80 @@ import { - Controller, - Post, - Get, - Param, - Delete, - Body, - Patch, + Controller, + Post, + Get, + Param, + Patch, + Body, + Req, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { SubscriptionService } from '../services/subscription.service'; +import { CustomerSubscriptionService } from '../services/subscription.service'; import { CustomerSubscription } from '../entities/customer.entity'; import { - CreateSubscriptionDto, - CreateSubscriptionPlanDto, - UpdateSubscriptionPlanDto, - UpdateSubscriptionStatusDto, + CreateSubscriptionDto, + UpdateSubscriptionStatusDto, } from '../dtos/subscription.dto'; import { ConfigService } from '@nestjs/config'; -import { SubscriptionPlan } from '../entities/subscription.entity'; const config = new ConfigService(); -/** - * Controller for managing customer subscriptions and subscription plans. - */ -@ApiTags('subscriptions') -@Controller({ path: 'subscriptions', version: config.get('API_VERSION') }) -export class SubscriptionController { - constructor(private readonly subscriptionService: SubscriptionService) {} +@ApiTags('Subscription') +@Controller({ path: 'subscription', version: config.get('API_VERSION') }) +export class CustomerSubscriptionController { + constructor( + private readonly customerSubscriptionService: CustomerSubscriptionService, + ) { } - /** - * Creates a new customer subscription. - * - * @param createSubscriptionDto - DTO containing data to create a new customer subscription. - * @returns The newly created CustomerSubscription entity. - */ - @Post('subscribe') - @ApiOperation({ summary: 'Create a new customer subscription' }) - @ApiResponse({ - status: 201, - description: 'The subscription has been successfully created.', - type: CustomerSubscription, - }) - createCustomerSubscription( - @Body() createSubscriptionDto: CreateSubscriptionDto, - ): Promise { - return this.subscriptionService.createCustomerSubscription( - createSubscriptionDto, - ); - } + @Post('subscribe') + @ApiOperation({ summary: 'Create a new customer subscription' }) + @ApiResponse({ + status: 201, + description: 'The subscription has been successfully created.', + type: CustomerSubscription, + }) + createCustomerSubscription( + @Body() createSubscriptionDto: CreateSubscriptionDto, + @Req() req: any, + ): Promise { + return this.customerSubscriptionService.createCustomerSubscription( + createSubscriptionDto, + req.transactionManager, + ); + } - /** - * Creates a new subscription plan. - * - * @param createSubscriptionPlanDto - DTO containing data to create a new subscription plan. - * @returns The newly created SubscriptionPlan entity. - */ - @Post('plan') - @ApiOperation({ summary: 'Create a new subscription plan' }) - @ApiResponse({ - status: 201, - description: 'The subscription plan has been successfully created.', - type: SubscriptionPlan, - }) - createSubscriptionPlan( - @Body() createSubscriptionPlanDto: CreateSubscriptionPlanDto, - ): Promise { - return this.subscriptionService.createSubscriptionPlan( - createSubscriptionPlanDto, - ); - } + @Get(':userId') + @ApiOperation({ summary: 'Get all subscriptions for a user' }) + @ApiResponse({ + status: 200, + description: 'List of subscriptions for the user.', + type: [CustomerSubscription], + }) + getCustomerSubscriptions( + @Param('userId') userId: string, + @Req() req: any, + ): Promise { + return this.customerSubscriptionService.getCustomerSubscriptions( + userId, + req.transactionManager, + ); + } - /** - * Retrieves all active subscription plans. - * - * @returns An array of SubscriptionPlan entities. - */ - @Get('plans') - @ApiOperation({ summary: 'Get all active subscription plans' }) - @ApiResponse({ - status: 200, - description: 'List of subscription plans.', - type: [SubscriptionPlan], - }) - plans(): Promise { - return this.subscriptionService.getSubscriptionPlans(); - } - - /** - * Retrieves all subscriptions for a specific user. - * - * @param userId - The ID of the user. - * @returns An array of CustomerSubscription entities. - */ - @Get(':userId') - @ApiOperation({ summary: 'Get all subscriptions for a user' }) - @ApiResponse({ - status: 200, - description: 'List of subscriptions for the user.', - type: [CustomerSubscription], - }) - getCustomerSubscriptions( - @Param('userId') userId: string, - ): Promise { - return this.subscriptionService.getCustomerSubscriptions(userId); - } - - /** - * Updates the status of a customer subscription. - * - * @param subscriptionId - The ID of the subscription to update. - * @param updateSubscriptionStatusDto - DTO containing the new subscription status. - * @returns The updated CustomerSubscription entity. - */ - @Patch(':subscriptionId/status') - @ApiOperation({ summary: 'Update the status of a customer subscription' }) - @ApiResponse({ - status: 200, - description: 'The subscription status has been updated.', - type: CustomerSubscription, - }) - updateSubscriptionStatus( - @Param('subscriptionId') subscriptionId: string, - @Body() updateSubscriptionStatusDto: UpdateSubscriptionStatusDto, - ): Promise { - return this.subscriptionService.updateSubscriptionStatus( - subscriptionId, - updateSubscriptionStatusDto, - ); - } - - /** - * Retrieves a subscription plan by its ID. - * - * @param id - The ID of the subscription plan to retrieve. - * @returns The found SubscriptionPlan entity. - */ - @Get('plan/:id') - @ApiOperation({ summary: 'Get a subscription plan by ID' }) - @ApiResponse({ - status: 200, - description: 'The subscription plan details.', - type: SubscriptionPlan, - }) - getSubscriptionPlanById(@Param('id') id: string): Promise { - return this.subscriptionService.getSubscriptionPlanById(id); - } - - /** - * Updates a subscription plan by its ID. - * - * @param id - The ID of the subscription plan to update. - * @param updateSubscriptionPlanDto - DTO containing the updated data for the subscription plan. - * @returns The updated SubscriptionPlan entity. - */ - @Patch('plan/:id') - @ApiOperation({ summary: 'Update a subscription plan by ID' }) - @ApiResponse({ - status: 200, - description: 'The subscription plan has been updated.', - type: SubscriptionPlan, - }) - updateSubscriptionPlan( - @Param('id') id: string, - @Body() updateSubscriptionPlanDto: UpdateSubscriptionPlanDto, - ): Promise { - return this.subscriptionService.updateSubscriptionPlan( - id, - updateSubscriptionPlanDto, - ); - } - - /** - * Deletes a subscription plan by its ID. - * - * @param id - The ID of the subscription plan to delete. - */ - @Delete('plan/:id') - @ApiOperation({ summary: 'Delete a subscription plan by ID' }) - @ApiResponse({ - status: 204, - description: 'The subscription plan has been deleted.', - }) - deleteSubscriptionPlan(@Param('id') id: string): Promise { - return this.subscriptionService.deleteSubscriptionPlan(id); - } + @Patch(':subscriptionId/status') + @ApiOperation({ summary: 'Update the status of a customer subscription' }) + @ApiResponse({ + status: 200, + description: 'The subscription status has been updated.', + type: CustomerSubscription, + }) + updateSubscriptionStatus( + @Param('subscriptionId') subscriptionId: string, + @Body() updateSubscriptionStatusDto: UpdateSubscriptionStatusDto, + @Req() req: any, + ): Promise { + return this.customerSubscriptionService.updateSubscriptionStatus( + subscriptionId, + updateSubscriptionStatusDto, + req.transactionManager, + ); + } } diff --git a/app/src/controllers/system-setting.controller.ts b/app/src/controllers/system-setting.controller.ts new file mode 100644 index 0000000..cba1357 --- /dev/null +++ b/app/src/controllers/system-setting.controller.ts @@ -0,0 +1,155 @@ +import { + Controller, + Post, + Body, + Get, + Param, + Patch, + Delete, + Req, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { + CreateSystemSettingDto, + ResetSystemSettingDto, + UpdateSystemSettingDto, +} from '../dtos/settings.dto'; +import { SystemSetting } from '../entities/system-settings.entity'; +import { SystemSettingService } from '../services/setting.service'; +import { ConfigService } from '@nestjs/config'; + +const config = new ConfigService(); + +/** + * Controller for managing system settings. + */ +@ApiTags('Configurations') +@Controller({ path: 'core/settings', version: config.get('API_VERSION') }) +export class SystemSettingController { + constructor(private readonly systemSettingService: SystemSettingService) { } + + /** + * Creates a new system setting. + * + * @param createSystemSettingDto - DTO containing data to create a new system setting. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The newly created SystemSetting entity. + */ + @Post() + @ApiOperation({ summary: 'Create a new system setting' }) + @ApiResponse({ + status: 201, + description: 'The setting has been successfully created.', + type: SystemSetting, + }) + async create( + @Body() createSystemSettingDto: CreateSystemSettingDto, + @Req() req: any, + ): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.create(createSystemSettingDto, entityManager); + } + + /** + * Retrieves all system settings. + * + * @param req - The HTTP request object, which contains the transaction manager. + * @returns An array of SystemSetting entities. + */ + @Get() + @ApiOperation({ summary: 'Retrieve all system settings' }) + @ApiResponse({ + status: 200, + description: 'Array of settings retrieved.', + type: [SystemSetting], + }) + async findAll(@Req() req: any): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.findAll(entityManager); + } + + /** + * Retrieves a single system setting by ID. + * + * @param id - The ID of the system setting to retrieve. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The found SystemSetting entity. + */ + @Get(':id') + @ApiOperation({ summary: 'Retrieve a single system setting by ID' }) + @ApiResponse({ + status: 200, + description: 'System setting retrieved.', + type: SystemSetting, + }) + async findOne(@Param('id') id: string, @Req() req: any): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.findOne(id, entityManager); + } + + /** + * Updates a system setting by ID. + * + * @param id - The ID of the system setting to update. + * @param updateSystemSettingDto - DTO containing the updated data for the system setting. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The updated SystemSetting entity. + */ + @Patch(':id') + @ApiOperation({ summary: 'Update a system setting by ID' }) + @ApiResponse({ + status: 200, + description: 'The setting has been successfully updated.', + type: SystemSetting, + }) + async update( + @Param('id') id: string, + @Body() updateSystemSettingDto: UpdateSystemSettingDto, + @Req() req: any, + ): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.update(id, updateSystemSettingDto, entityManager); + } + + /** + * Deletes a system setting by ID. + * + * @param id - The ID of the system setting to delete. + * @param req - The HTTP request object, which contains the transaction manager. + */ + @Delete(':id') + @ApiOperation({ summary: 'Delete a system setting by ID' }) + @ApiResponse({ + status: 200, + description: 'The setting has been successfully deleted.', + }) + async remove(@Param('id') id: string, @Req() req: any): Promise { + const entityManager = req.transactionManager; + await this.systemSettingService.remove(id, entityManager); + } + + /** + * Resets a system setting to its default value by code. + * + * @param resetSystemSettingDto - DTO containing the code of the system setting to reset. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The reset SystemSetting entity. + */ + @Patch('reset') + @ApiOperation({ summary: 'Reset a system setting by code' }) + @ApiResponse({ + status: 200, + description: 'The setting has been reset to its default value.', + type: SystemSetting, + }) + async resetSetting( + @Body() resetSystemSettingDto: ResetSystemSettingDto, + @Req() req: any, + ): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.resetSetting( + resetSystemSettingDto.code, + entityManager, + ); + } +} diff --git a/app/src/controllers/webhooks.controller.ts b/app/src/controllers/webhooks.controller.ts index 3af0842..6b7b15c 100644 --- a/app/src/controllers/webhooks.controller.ts +++ b/app/src/controllers/webhooks.controller.ts @@ -9,11 +9,16 @@ import { StripeService } from '../services/stripe.service'; import { PaymentService } from '../services/payment.service'; import { PaymentMethodCode } from '../utils/enums'; import Stripe from 'stripe'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; + +const config = new ConfigService(); /** * Controller to handle incoming webhooks from various services. */ -@Controller('webhooks') +@ApiTags('Payment Webhooks') +@Controller({ path: 'webhooks', version: config.get('API_VERSION') }) export class WebhooksController { constructor( private readonly stripeService: StripeService, @@ -28,6 +33,7 @@ export class WebhooksController { * @returns Acknowledgment of the event receipt. * @throws BadRequestException if the event cannot be verified. */ + @ApiOperation({ summary: 'Stripe payment webhook handler' }) @Post('stripe') async handleStripeWebhook( @Body() payload: any, diff --git a/app/src/entities/base.entity.ts b/app/src/entities/base.entity.ts index fe03c00..a0844aa 100644 --- a/app/src/entities/base.entity.ts +++ b/app/src/entities/base.entity.ts @@ -5,8 +5,11 @@ import { DeleteDateColumn, ManyToOne, BaseEntity as TypeORMBaseEntity, + BeforeInsert, } from 'typeorm'; import { DataLookup } from './data-lookup.entity'; +import { DataLookupService } from '../services/data-lookup.service'; +import { ObjectState } from '../utils/enums'; export abstract class BaseEntity extends TypeORMBaseEntity { @PrimaryGeneratedColumn('uuid') @@ -23,4 +26,15 @@ export abstract class BaseEntity extends TypeORMBaseEntity { @DeleteDateColumn({ nullable: true }) deletedDate: Date; + + constructor(private readonly dataLookupService: DataLookupService) { + super(); + } + + @BeforeInsert() + async setObjectState() { + if (!this.objectState) { + this.objectState = await this.dataLookupService.getDefaultData(ObjectState.TYPE); + } + } } diff --git a/app/src/interceptors/transaction.interceptor.ts b/app/src/interceptors/transaction.interceptor.ts new file mode 100644 index 0000000..46cf780 --- /dev/null +++ b/app/src/interceptors/transaction.interceptor.ts @@ -0,0 +1,40 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable, from } from 'rxjs'; +import { DataSource } from 'typeorm'; +import { mergeMap } from 'rxjs/operators'; + +/** + * TransactionInterceptor handles wrapping each HTTP request in a database transaction. + * This ensures that all database operations within a single request are either + * fully completed or fully rolled back, maintaining atomicity. + */ +@Injectable() +export class TransactionInterceptor implements NestInterceptor { + /** + * Constructor to inject the TypeORM data source. + * @param {DataSource} dataSource - The TypeORM data source instance to manage transactions. + */ + constructor(private readonly dataSource: DataSource) { } + + /** + * Intercepts the execution context (HTTP request) and wraps it in a database transaction. + * + * @param {ExecutionContext} context - The current execution context of the request. + * @param {CallHandler} next - The next handler in the request pipeline. + * @returns {Observable} - The resulting observable after the transaction is applied. + */ + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + + // Begin a new transaction using the TypeORM data source. + return from(this.dataSource.transaction(async (manager) => { + // Attach the transaction manager to the request object for use in downstream services. + request.transactionManager = manager; + // Handle the request and convert the result to a Promise. + return next.handle().toPromise(); + })).pipe( + // Use mergeMap to ensure the final result is an observable that can handle inner observables. + mergeMap(result => result) + ); + } +} diff --git a/app/src/main.ts b/app/src/main.ts index d53a725..dbcf361 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -6,6 +6,8 @@ import { AllExceptionsFilter } from './exceptions/all-exceptions.filter'; import { DatabaseExceptionFilter } from './exceptions/database-exception.filter'; import { HttpExceptionFilter } from './exceptions/http-exception.filter'; import { ValidationExceptionFilter } from './exceptions/validation-exception.filter'; +import { DataSource } from 'typeorm'; +import { TransactionInterceptor } from './interceptors/transaction.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -27,6 +29,12 @@ async function bootstrap() { new DatabaseExceptionFilter(), ); + // Retrieve the DataSource instance from the NestJS container + const dataSource = app.get(DataSource); + + // Apply the TransactionInterceptor globally + app.useGlobalInterceptors(new TransactionInterceptor(dataSource)); + // Swagger configuration const config = new DocumentBuilder() .setTitle('SaaS Subscription Billing') diff --git a/app/src/services/base.service.ts b/app/src/services/base.service.ts index 0d87b46..c7242a1 100644 --- a/app/src/services/base.service.ts +++ b/app/src/services/base.service.ts @@ -3,6 +3,7 @@ import { EntityTarget, FindOptionsWhere, Repository, + EntityManager, } from 'typeorm'; import { NotFoundException } from '@nestjs/common'; import { BaseEntity } from '../entities/base.entity'; @@ -21,71 +22,41 @@ export class GenericService { /** * Marks an entity as deleted by setting its state to 'DELETED' and updating the deletion date. - * Uses a database transaction to ensure data integrity. + * Uses the provided EntityManager to ensure it participates in the transaction if one is active. * * @param id - The ID of the entity to be marked as deleted. + * @param manager - The EntityManager to be used, typically provided from the controller. * @throws NotFoundException if the entity or the 'DELETED' state is not found. */ - async destroy(id: string): Promise { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const entity = await queryRunner.manager.findOne(this.entity, { - where: { id } as FindOptionsWhere, - }); - - if (!entity) { - throw new NotFoundException( - `${this.getEntityName()} with id ${id} not found`, - ); - } - - const deletedState = await this.getDeletedState(queryRunner); - - entity.objectState = deletedState; - entity.deletedDate = new Date(); + async destroy(id: string, manager: EntityManager): Promise { + const entity = await manager.findOne(this.entity, { + where: { id } as FindOptionsWhere, + }); - await queryRunner.manager.save(entity); - await queryRunner.commitTransaction(); - } catch (error) { - await queryRunner.rollbackTransaction(); - throw error; - } finally { - await queryRunner.release(); + if (!entity) { + throw new NotFoundException( + `${this.getEntityName()} with id ${id} not found`, + ); } - } - /** - * Saves an entity with a default state if it is a new entity. - * - * @param entity - The entity to be saved. - * @param defaultStateType - The type of the default state to be applied to the entity. - * @returns A Promise that resolves to the saved entity. - * @throws NotFoundException if the default state for the given type is not found. - */ - async saveEntityWithDefaultState( - entity: T, - defaultStateType: string, - ): Promise { - if (!entity.id) { - const defaultState = await this.getDefaultState(defaultStateType); - entity.objectState = defaultState; - } + const deletedState = await this.getDeletedState(manager); - return this.repository.save(entity); + entity.objectState = deletedState; + entity.deletedDate = new Date(); + + await manager.save(entity); } /** * Retrieves the 'DELETED' state from the DataLookup table. + * Uses the provided EntityManager to ensure it participates in the transaction if one is active. * - * @param queryRunner - The query runner used for the transaction. + * @param manager - The EntityManager to be used, typically provided from the controller. * @returns A Promise that resolves to the 'DELETED' state. * @throws NotFoundException if the 'DELETED' state is not found. */ - private async getDeletedState(queryRunner: any): Promise { - const deletedState = await queryRunner.manager.findOne(DataLookup, { + private async getDeletedState(manager: EntityManager): Promise { + const deletedState = await manager.findOne(DataLookup, { where: { value: ObjectState.DELETED }, }); @@ -96,29 +67,6 @@ export class GenericService { return deletedState; } - /** - * Retrieves the default state from the DataLookup table. - * - * @param defaultStateType - The type of the default state to retrieve. - * @returns A Promise that resolves to the default state. - * @throws NotFoundException if the default state for the given type is not found. - */ - private async getDefaultState(defaultStateType: string): Promise { - const defaultState = await this.dataSource - .getRepository(DataLookup) - .findOne({ - where: { type: defaultStateType, is_default: true }, - }); - - if (!defaultState) { - throw new NotFoundException( - `Unable to find default state for type ${defaultStateType}, please seed fixture data.`, - ); - } - - return defaultState; - } - /** * Retrieves the entity name from the EntityTarget for error messages. * diff --git a/app/src/services/data-lookup.service.ts b/app/src/services/data-lookup.service.ts index 5467225..330acd0 100644 --- a/app/src/services/data-lookup.service.ts +++ b/app/src/services/data-lookup.service.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { CreateDataLookupDto } from '../dtos/core.dto'; import { DataLookup } from '../entities/data-lookup.entity'; -import { Repository } from 'typeorm'; +import { Repository, EntityManager } from 'typeorm'; @Injectable() export class DataLookupService { @@ -15,59 +15,94 @@ export class DataLookupService { * Checks if a DataLookup entity with the specified value exists. * * @param value - The value to check for existence in the DataLookup table. + * @param manager - The EntityManager provided by the transaction. * @returns A Promise that resolves to `true` if the value exists, or `false` otherwise. */ - async existsByValue(value: string): Promise { - return (await this.lookupRepository.count({ where: { value } })) > 0; + async existsByValue(value: string, manager: EntityManager): Promise { + return (await manager.count(DataLookup, { where: { value } })) > 0; } /** * Creates a new DataLookup entity. * * @param createDataLookupDto - The data transfer object containing the details for the new DataLookup entity. + * @param manager - The EntityManager provided by the transaction. * @returns A Promise that resolves to the created DataLookup entity. */ - async create(createDataLookupDto: CreateDataLookupDto): Promise { - const lookupData = this.lookupRepository.create(createDataLookupDto); - return await this.lookupRepository.save(lookupData); + async create( + createDataLookupDto: CreateDataLookupDto, + manager: EntityManager, + ): Promise { + const lookupData = manager.create(DataLookup, createDataLookupDto); + return await manager.save(DataLookup, lookupData); } /** * Creates multiple DataLookup entities in bulk. * * @param createDataLookupDtos - An array of data transfer objects, each containing the details for a new DataLookup entity. + * @param manager - The EntityManager provided by the transaction. * @returns A Promise that resolves to an array of the created DataLookup entities. */ async createBulk( createDataLookupDtos: CreateDataLookupDto[], + manager: EntityManager, ): Promise { const lookupDataList = createDataLookupDtos.map((dto) => - this.lookupRepository.create(dto), + manager.create(DataLookup, dto), ); - return await this.lookupRepository.save(lookupDataList); + return await manager.save(DataLookup, lookupDataList); } /** * Retrieves all DataLookup entities. * + * @param manager - The EntityManager provided by the transaction. * @returns A Promise that resolves to an array of DataLookup entities. */ - async findAll(): Promise { - return await this.lookupRepository.find(); + async findAll(manager: EntityManager): Promise { + return await manager.find(DataLookup); } /** * Retrieves a DataLookup entity by its ID. * * @param id - The ID of the DataLookup entity to retrieve. + * @param manager - The EntityManager provided by the transaction. * @returns A Promise that resolves to the found DataLookup entity. * @throws NotFoundException if the entity with the specified ID is not found. */ - async findOne(id: string): Promise { - const lookupData = await this.lookupRepository.findOneBy({ id }); + async findOne(id: string, manager: EntityManager): Promise { + const lookupData = await manager.findOneBy(DataLookup, { id }); if (!lookupData) { throw new NotFoundException(`Data Lookup with ID ${id} not found`); } return lookupData; } + + /** + * Retrieves the default state from the DataLookup table. + * + * @param defaultStateType - The type of the default state to retrieve. + * @param manager - The EntityManager provided by the transaction. + * @returns A Promise that resolves to the default state. + * @throws NotFoundException if the default state for the given type is not found. + */ + async getDefaultData( + defaultStateType: string, + manager?: EntityManager, + ): Promise { + const repo = manager ? manager.getRepository(DataLookup) : this.lookupRepository; + const defaultState = await repo.findOne({ + where: { type: defaultStateType, is_default: true }, + }); + + if (!defaultState) { + throw new NotFoundException( + `Unable to find default state for type ${defaultStateType}, please seed fixture data.`, + ); + } + + return defaultState; + } } diff --git a/app/src/services/setting.service.ts b/app/src/services/setting.service.ts index b5e0a8f..2ad07fc 100644 --- a/app/src/services/setting.service.ts +++ b/app/src/services/setting.service.ts @@ -4,7 +4,7 @@ import { BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, Repository, EntityManager } from 'typeorm'; import { SystemSetting } from '../entities/system-settings.entity'; import { CreateSystemSettingDto, @@ -17,7 +17,7 @@ export class SystemSettingService extends GenericService { constructor( @InjectRepository(SystemSetting) private readonly systemSettingRepository: Repository, - dataSource: DataSource, + protected readonly dataSource: DataSource, ) { super(SystemSetting, dataSource); } @@ -26,36 +26,40 @@ export class SystemSettingService extends GenericService { * Creates a new system setting with the default value. * * @param createSystemSettingDto - DTO containing the data for creating a new system setting. + * @param manager - The EntityManager provided by the transaction. * @returns The newly created SystemSetting entity. */ async create( createSystemSettingDto: CreateSystemSettingDto, + manager: EntityManager, // Accept EntityManager as a parameter ): Promise { createSystemSettingDto.currentValue = createSystemSettingDto.defaultValue; // Enforce currentValue to match defaultValue const systemSetting = this.systemSettingRepository.create( createSystemSettingDto, ); - return await this.systemSettingRepository.save(systemSetting); + return await manager.save(SystemSetting, systemSetting); } /** * Retrieves all system settings. * + * @param manager - The EntityManager provided by the transaction. * @returns An array of SystemSetting entities. */ - async findAll(): Promise { - return await this.systemSettingRepository.find(); + async findAll(manager: EntityManager): Promise { + return await manager.find(SystemSetting); } /** * Retrieves a system setting by its ID. * * @param id - The ID of the system setting to retrieve. + * @param manager - The EntityManager provided by the transaction. * @returns The found SystemSetting entity. * @throws NotFoundException if the system setting is not found. */ - async findOne(id: string): Promise { - const setting = await this.systemSettingRepository.findOne({ + async findOne(id: string, manager: EntityManager): Promise { + const setting = await manager.findOne(SystemSetting, { where: { id }, }); if (!setting) { @@ -69,38 +73,42 @@ export class SystemSettingService extends GenericService { * * @param id - The ID of the system setting to update. * @param updateSystemSettingDto - DTO containing the updated data for the system setting. + * @param manager - The EntityManager provided by the transaction. * @returns The updated SystemSetting entity. * @throws NotFoundException if the system setting is not found. */ async update( id: string, updateSystemSettingDto: UpdateSystemSettingDto, + manager: EntityManager, // Accept EntityManager as a parameter ): Promise { - const setting = await this.findOne(id); + const setting = await this.findOne(id, manager); Object.assign(setting, updateSystemSettingDto); - return await this.systemSettingRepository.save(setting); + return await manager.save(SystemSetting, setting); } /** * Removes a system setting by its ID. * * @param id - The ID of the system setting to remove. + * @param manager - The EntityManager provided by the transaction. * @returns A promise that resolves when the system setting is removed. */ - async remove(id: string): Promise { - await this.destroy(id); + async remove(id: string, manager: EntityManager): Promise { + await manager.delete(SystemSetting, id); } /** * Resets a system setting's current value to its default value based on its code. * * @param code - The code of the system setting to reset. + * @param manager - The EntityManager provided by the transaction. * @returns The updated SystemSetting entity. * @throws NotFoundException if the system setting is not found. * @throws BadRequestException if the current value is already the same as the default value. */ - async resetSetting(code: string): Promise { - const setting = await this.systemSettingRepository.findOne({ + async resetSetting(code: string, manager: EntityManager): Promise { + const setting = await manager.findOne(SystemSetting, { where: { code }, }); if (!setting) { @@ -109,7 +117,7 @@ export class SystemSettingService extends GenericService { if (setting.currentValue !== setting.defaultValue) { setting.currentValue = setting.defaultValue; - return await this.systemSettingRepository.save(setting); + return await manager.save(SystemSetting, setting); } throw new BadRequestException( diff --git a/app/src/services/subscription-plan.service.ts b/app/src/services/subscription-plan.service.ts new file mode 100644 index 0000000..1689711 --- /dev/null +++ b/app/src/services/subscription-plan.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, Repository, DataSource } from 'typeorm'; +import { SubscriptionPlan } from '../entities/subscription.entity'; +import { DataLookup } from '../entities/data-lookup.entity'; +import { + CreateSubscriptionPlanDto, + UpdateSubscriptionPlanDto, +} from '../dtos/subscription.dto'; +import { GenericService } from './base.service'; +import { SubscriptionPlanState } from '../utils/enums'; + +@Injectable() +export class SubscriptionPlanService extends GenericService { + constructor( + @InjectRepository(SubscriptionPlan) + private readonly subscriptionPlanRepository: Repository, + @InjectRepository(DataLookup) + private readonly dataLookupRepository: Repository, + dataSource: DataSource, + ) { + super(SubscriptionPlan, dataSource); + } + + async createSubscriptionPlan( + createSubscriptionPlanDto: CreateSubscriptionPlanDto, + manager: EntityManager, + ): Promise { + const { name, description, price, billingCycleDays, prorate } = + createSubscriptionPlanDto; + + const planDefaultState = await manager.findOne(DataLookup, { + where: { type: SubscriptionPlanState.TYPE, is_default: true }, + }); + if (!planDefaultState) { + throw new NotFoundException( + `Unable to find subscription plan default state, please seed fixture data.`, + ); + } + + const newPlan = this.subscriptionPlanRepository.create({ + name, + description, + price, + billingCycleDays, + status: planDefaultState, + prorate, + }); + + return manager.save(SubscriptionPlan, newPlan); + } + + async getSubscriptionPlans( + manager: EntityManager, + ): Promise { + return manager.find(SubscriptionPlan, { + relations: ['status', 'objectState'], + }); + } + + async getSubscriptionPlanById( + id: string, + manager: EntityManager, + ): Promise { + const plan = await manager.findOne(SubscriptionPlan, { + where: { id }, + relations: ['status', 'objectState'], + }); + + if (!plan) { + throw new NotFoundException(`Subscription plan with ID ${id} not found`); + } + + return plan; + } + + async updateSubscriptionPlan( + id: string, + updateSubscriptionPlanDto: UpdateSubscriptionPlanDto, + manager: EntityManager, + ): Promise { + const plan = await this.getSubscriptionPlanById(id, manager); + + const { name, description, price, billingCycleDays, statusId, prorate } = + updateSubscriptionPlanDto; + + if (statusId) { + const status = await manager.findOne(DataLookup, { + where: { id: statusId }, + }); + if (!status) { + throw new NotFoundException(`Status with ID ${statusId} not found`); + } + plan.status = status; + } + + plan.name = name ?? plan.name; + plan.description = description ?? plan.description; + plan.price = price ?? plan.price; + plan.billingCycleDays = billingCycleDays ?? plan.billingCycleDays; + plan.prorate = prorate !== undefined ? prorate : plan.prorate; + + return manager.save(SubscriptionPlan, plan); + } + + async deleteSubscriptionPlan( + id: string, + manager: EntityManager, + ): Promise { + await this.destroy(id, manager); + } +} diff --git a/app/src/services/subscription.service.ts b/app/src/services/subscription.service.ts index 4eaa1be..ddb72c6 100644 --- a/app/src/services/subscription.service.ts +++ b/app/src/services/subscription.service.ts @@ -1,58 +1,40 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { EntityManager, Repository, DataSource } from 'typeorm'; import { CustomerSubscription } from '../entities/customer.entity'; import { User } from '../entities/user.entity'; import { SubscriptionPlan } from '../entities/subscription.entity'; import { DataLookup } from '../entities/data-lookup.entity'; import { - CreateSubscriptionDto, - CreateSubscriptionPlanDto, - UpdateSubscriptionPlanDto, + CreateSubscriptionDto, UpdateSubscriptionStatusDto, } from '../dtos/subscription.dto'; -import { - ObjectState, - SubscriptionPlanState, - SubscriptionStatus, -} from '../utils/enums'; import { GenericService } from './base.service'; +import { SubscriptionStatus } from '../utils/enums'; @Injectable() -export class SubscriptionService extends GenericService { +export class CustomerSubscriptionService extends GenericService { constructor( @InjectRepository(CustomerSubscription) - private readonly customerSubscriptionRepository: Repository, - @InjectRepository(User) - private readonly userRepository: Repository, - @InjectRepository(SubscriptionPlan) - private readonly subscriptionPlanRepository: Repository, - @InjectRepository(DataLookup) - private readonly dataLookupRepository: Repository, + private readonly customerSubscriptionRepository: Repository, dataSource: DataSource, ) { - super(SubscriptionPlan, dataSource); + super(CustomerSubscription, dataSource); } - /** - * Creates a new customer subscription. - * - * @param createSubscriptionDto - DTO containing data to create a customer subscription. - * @returns The newly created CustomerSubscription entity. - * @throws NotFoundException if the user, subscription plan, or subscription status is not found. - */ async createCustomerSubscription( createSubscriptionDto: CreateSubscriptionDto, + manager: EntityManager, ): Promise { const { userId, subscriptionPlanId } = createSubscriptionDto; - const user = await this.userRepository.findOneBy({ id: userId }); + const user = await manager.findOne(User, { where: { id: userId } }); if (!user) { throw new NotFoundException(`User with ID ${userId} not found`); } - const subscriptionPlan = await this.subscriptionPlanRepository.findOneBy({ - id: subscriptionPlanId, + const subscriptionPlan = await manager.findOne(SubscriptionPlan, { + where: { id: subscriptionPlanId }, }); if (!subscriptionPlan) { throw new NotFoundException( @@ -60,8 +42,8 @@ export class SubscriptionService extends GenericService { ); } - const subscriptionStatus = await this.dataLookupRepository.findOneBy({ - value: SubscriptionStatus.PENDING, + const subscriptionStatus = await manager.findOne(DataLookup, { + where: { value: SubscriptionStatus.PENDING }, }); if (!subscriptionStatus) { throw new NotFoundException( @@ -73,52 +55,38 @@ export class SubscriptionService extends GenericService { user, subscriptionPlan, subscriptionStatus, - endDate: null, - startDate: Date.now(), + startDate: new Date(), nextBillingDate: new Date( Date.now() + subscriptionPlan.billingCycleDays * 24 * 60 * 60 * 1000, - ).getTime(), + ), }); - return this.customerSubscriptionRepository.save(newSubscription); + return manager.save(CustomerSubscription, newSubscription); } - /** - * Retrieves all customer subscriptions for a given user. - * - * @param userId - The ID of the user whose subscriptions are to be retrieved. - * @returns An array of CustomerSubscription entities. - * @throws NotFoundException if the user is not found. - */ async getCustomerSubscriptions( userId: string, + manager: EntityManager, ): Promise { - const user = await this.userRepository.findOneBy({ id: userId }); + const user = await manager.findOne(User, { where: { id: userId } }); if (!user) { throw new NotFoundException(`User with ID ${userId} not found`); } - return this.customerSubscriptionRepository.find({ + return manager.find(CustomerSubscription, { where: { user }, relations: ['subscriptionPlan', 'subscriptionStatus'], }); } - /** - * Updates the status of a customer subscription. - * - * @param subscriptionId - The ID of the subscription to update. - * @param updateSubscriptionStatusDto - DTO containing the new subscription status and optional end date. - * @returns The updated CustomerSubscription entity. - * @throws NotFoundException if the subscription or subscription status is not found. - */ async updateSubscriptionStatus( subscriptionId: string, updateSubscriptionStatusDto: UpdateSubscriptionStatusDto, + manager: EntityManager, ): Promise { const { subscriptionStatusId, endDate } = updateSubscriptionStatusDto; - const subscription = await this.customerSubscriptionRepository.findOne({ + const subscription = await manager.findOne(CustomerSubscription, { where: { id: subscriptionId }, relations: ['subscriptionStatus'], }); @@ -129,8 +97,8 @@ export class SubscriptionService extends GenericService { ); } - const subscriptionStatus = await this.dataLookupRepository.findOneBy({ - id: subscriptionStatusId, + const subscriptionStatus = await manager.findOne(DataLookup, { + where: { id: subscriptionStatusId }, }); if (!subscriptionStatus) { throw new NotFoundException( @@ -141,118 +109,6 @@ export class SubscriptionService extends GenericService { subscription.subscriptionStatus = subscriptionStatus; subscription.endDate = endDate || subscription.endDate; - return this.customerSubscriptionRepository.save(subscription); - } - - /** - * Creates a new subscription plan. - * - * @param createSubscriptionPlanDto - DTO containing data to create a new subscription plan. - * @returns The newly created SubscriptionPlan entity. - * @throws NotFoundException if the default subscription plan state is not found. - */ - async createSubscriptionPlan( - createSubscriptionPlanDto: CreateSubscriptionPlanDto, - ): Promise { - const { name, description, price, billingCycleDays, prorate } = - createSubscriptionPlanDto; - - const planDefaultState = await this.dataLookupRepository.findOneBy({ - type: SubscriptionPlanState.TYPE, - is_default: true, - }); - if (!planDefaultState) { - throw new NotFoundException( - `Unable to find subscription plan default state, please seed fixture data.`, - ); - } - - const newPlan = this.subscriptionPlanRepository.create({ - name, - description, - price, - billingCycleDays, - status: planDefaultState, - prorate, - }); - - return this.saveEntityWithDefaultState(newPlan, ObjectState.TYPE); - } - - /** - * Retrieves all subscription plans. - * - * @returns An array of SubscriptionPlan entities. - */ - async getSubscriptionPlans(): Promise { - return this.subscriptionPlanRepository.find({ - relations: ['status', 'objectState'], - }); - } - - /** - * Retrieves a subscription plan by its ID. - * - * @param id - The ID of the subscription plan to retrieve. - * @returns The found SubscriptionPlan entity. - * @throws NotFoundException if the subscription plan is not found. - */ - async getSubscriptionPlanById(id: string): Promise { - const plan = await this.subscriptionPlanRepository.findOne({ - where: { id }, - relations: ['status', 'objectState'], - }); - - if (!plan) { - throw new NotFoundException(`Subscription plan with ID ${id} not found`); - } - - return plan; - } - - /** - * Updates an existing subscription plan. - * - * @param id - The ID of the subscription plan to update. - * @param updateSubscriptionPlanDto - DTO containing the updated data for the subscription plan. - * @returns The updated SubscriptionPlan entity. - * @throws NotFoundException if the subscription plan or status is not found. - */ - async updateSubscriptionPlan( - id: string, - updateSubscriptionPlanDto: UpdateSubscriptionPlanDto, - ): Promise { - const plan = await this.getSubscriptionPlanById(id); - - const { name, description, price, billingCycleDays, statusId, prorate } = - updateSubscriptionPlanDto; - - if (statusId) { - const status = await this.dataLookupRepository.findOneBy({ - id: statusId, - }); - if (!status) { - throw new NotFoundException(`Status with ID ${statusId} not found`); - } - plan.status = status; - } - - plan.name = name ?? plan.name; - plan.description = description ?? plan.description; - plan.price = price ?? plan.price; - plan.billingCycleDays = billingCycleDays ?? plan.billingCycleDays; - plan.prorate = prorate !== undefined ? prorate : plan.prorate; - - return this.subscriptionPlanRepository.save(plan); - } - - /** - * Deletes a subscription plan by its ID. - * - * @param id - The ID of the subscription plan to delete. - * @returns A promise that resolves when the subscription plan is deleted. - */ - deleteSubscriptionPlan(id: string): Promise { - return this.destroy(id); + return manager.save(CustomerSubscription, subscription); } } diff --git a/app/tests/controllers/lookup.controller.spec.ts b/app/tests/controllers/data-lookup.controller.spec.ts similarity index 76% rename from app/tests/controllers/lookup.controller.spec.ts rename to app/tests/controllers/data-lookup.controller.spec.ts index 5a2e398..e4752c5 100644 --- a/app/tests/controllers/lookup.controller.spec.ts +++ b/app/tests/controllers/data-lookup.controller.spec.ts @@ -1,14 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { DataLookupController } from '../../src/controllers/core.controller'; +import { DataLookupController } from '../../src/controllers/data-lookup.controller'; import { DataLookupService } from '../../src/services/data-lookup.service'; import { CreateDataLookupDto } from '../../src/dtos/core.dto'; -import { DataLookup } from '@app/entities/data-lookup.entity'; +import { DataLookup } from '../../src/entities/data-lookup.entity'; // Adjust the import path as needed +import { EntityManager } from 'typeorm'; describe('DataLookupController', () => { let controller: DataLookupController; let service: DataLookupService; + let entityManager: jest.Mocked; beforeEach(async () => { + entityManager = { + findOne: jest.fn(), + save: jest.fn(), + } as unknown as jest.Mocked; + const module: TestingModule = await Test.createTestingModule({ controllers: [DataLookupController], providers: [ @@ -21,6 +28,10 @@ describe('DataLookupController', () => { findOne: jest.fn(), }, }, + { + provide: EntityManager, + useValue: entityManager, + }, ], }).compile(); @@ -39,9 +50,9 @@ describe('DataLookupController', () => { jest.spyOn(service, 'create').mockResolvedValue(dataLookup); - const result = await controller.create(createDataLookupDto); + const result = await controller.create(createDataLookupDto, { transactionManager: entityManager }); expect(result).toEqual(dataLookup); - expect(service.create).toHaveBeenCalledWith(createDataLookupDto); + expect(service.create).toHaveBeenCalledWith(createDataLookupDto, entityManager); }); }); @@ -58,9 +69,9 @@ describe('DataLookupController', () => { jest.spyOn(service, 'createBulk').mockResolvedValue(dataLookups); - const result = await controller.createBulk(createDataLookupDtos); + const result = await controller.createBulk(createDataLookupDtos, { transactionManager: entityManager }); expect(result).toEqual(dataLookups); - expect(service.createBulk).toHaveBeenCalledWith(createDataLookupDtos); + expect(service.createBulk).toHaveBeenCalledWith(createDataLookupDtos, entityManager); }); }); @@ -70,7 +81,7 @@ describe('DataLookupController', () => { jest.spyOn(service, 'findAll').mockResolvedValue(dataLookups); - const result = await controller.findAll(); + const result = await controller.findAll(entityManager); expect(result).toEqual(dataLookups); expect(service.findAll).toHaveBeenCalled(); }); @@ -82,9 +93,9 @@ describe('DataLookupController', () => { jest.spyOn(service, 'findOne').mockResolvedValue(dataLookup); - const result = await controller.findOne('1'); + const result = await controller.findOne('1', { transactionManager: entityManager }); expect(result).toEqual(dataLookup); - expect(service.findOne).toHaveBeenCalledWith('1'); + expect(service.findOne).toHaveBeenCalledWith('1', entityManager); }); }); }); diff --git a/app/tests/controllers/settings.controller.spec.ts b/app/tests/controllers/settings.controller.spec.ts index 0800d0f..3e48f6c 100644 --- a/app/tests/controllers/settings.controller.spec.ts +++ b/app/tests/controllers/settings.controller.spec.ts @@ -1,14 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { SystemSettingController } from '../../src/controllers/core.controller'; +import { SystemSettingController } from '../../src/controllers/system-setting.controller'; import { SystemSettingService } from '../../src/services/setting.service'; import { CreateSystemSettingDto, UpdateSystemSettingDto, ResetSystemSettingDto } from '../../src/dtos/settings.dto'; import { SystemSetting } from '../../src/entities/system-settings.entity'; +import { EntityManager } from 'typeorm'; describe('SystemSettingController', () => { let controller: SystemSettingController; let service: SystemSettingService; + let entityManager: jest.Mocked; beforeEach(async () => { + entityManager = { + findOne: jest.fn(), + save: jest.fn(), + } as unknown as jest.Mocked; + const module: TestingModule = await Test.createTestingModule({ controllers: [SystemSettingController], providers: [ @@ -23,6 +30,10 @@ describe('SystemSettingController', () => { resetSetting: jest.fn(), }, }, + { + provide: EntityManager, + useValue: entityManager, + }, ], }).compile(); @@ -36,14 +47,14 @@ describe('SystemSettingController', () => { describe('create', () => { it('should create a new system setting', async () => { - const createSystemSettingDto: CreateSystemSettingDto = { code: 'test', defaultValue: 'value' } as CreateSystemSettingDto; + const createSystemSettingDto: CreateSystemSettingDto = { name: "Test Setting", code: 'test', defaultValue: 'value' } as CreateSystemSettingDto; const systemSetting = { id: '1', ...createSystemSettingDto } as SystemSetting; jest.spyOn(service, 'create').mockResolvedValue(systemSetting); - const result = await controller.create(createSystemSettingDto); + const result = await controller.create(createSystemSettingDto, { transactionManager: entityManager }); expect(result).toEqual(systemSetting); - expect(service.create).toHaveBeenCalledWith(createSystemSettingDto); + expect(service.create).toHaveBeenCalledWith(createSystemSettingDto, entityManager); }); }); @@ -53,7 +64,7 @@ describe('SystemSettingController', () => { jest.spyOn(service, 'findAll').mockResolvedValue(systemSettings); - const result = await controller.findAll(); + const result = await controller.findAll(entityManager); expect(result).toEqual(systemSettings); expect(service.findAll).toHaveBeenCalled(); }); @@ -65,9 +76,9 @@ describe('SystemSettingController', () => { jest.spyOn(service, 'findOne').mockResolvedValue(systemSetting); - const result = await controller.findOne('1'); + const result = await controller.findOne('1', { transactionManager: entityManager }); expect(result).toEqual(systemSetting); - expect(service.findOne).toHaveBeenCalledWith('1'); + expect(service.findOne).toHaveBeenCalledWith('1', entityManager); }); }); @@ -78,9 +89,9 @@ describe('SystemSettingController', () => { jest.spyOn(service, 'update').mockResolvedValue(systemSetting); - const result = await controller.update('1', updateSystemSettingDto); + const result = await controller.update('1', updateSystemSettingDto, { transactionManager: entityManager }); expect(result).toEqual(systemSetting); - expect(service.update).toHaveBeenCalledWith('1', updateSystemSettingDto); + expect(service.update).toHaveBeenCalledWith('1', updateSystemSettingDto, entityManager); }); }); @@ -88,9 +99,9 @@ describe('SystemSettingController', () => { it('should delete a system setting by ID', async () => { jest.spyOn(service, 'remove').mockResolvedValue(undefined); - const result = await controller.remove('1'); + const result = await controller.remove('1', { transactionManager: entityManager }); expect(result).toBeUndefined(); - expect(service.remove).toHaveBeenCalledWith('1'); + expect(service.remove).toHaveBeenCalledWith('1', entityManager); }); }); @@ -101,9 +112,9 @@ describe('SystemSettingController', () => { jest.spyOn(service, 'resetSetting').mockResolvedValue(systemSetting); - const result = await controller.resetSetting(resetSystemSettingDto); + const result = await controller.resetSetting(resetSystemSettingDto, { transactionManager: entityManager }); expect(result).toEqual(systemSetting); - expect(service.resetSetting).toHaveBeenCalledWith('test'); + expect(service.resetSetting).toHaveBeenCalledWith(resetSystemSettingDto.code, entityManager); }); }); }); diff --git a/app/tests/controllers/subscription-plan.controller.spec.ts b/app/tests/controllers/subscription-plan.controller.spec.ts new file mode 100644 index 0000000..ebba41d --- /dev/null +++ b/app/tests/controllers/subscription-plan.controller.spec.ts @@ -0,0 +1,104 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionPlanController } from '../../src/controllers/subscription-plan.controller'; +import { SubscriptionPlanService } from '../../src/services/subscription-plan.service'; +import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../../src/dtos/subscription.dto'; +import { SubscriptionPlan } from '../../src/entities/subscription.entity'; +import { EntityManager } from 'typeorm'; + +describe('SubscriptionPlanController', () => { + let controller: SubscriptionPlanController; + let service: SubscriptionPlanService; + let entityManager: jest.Mocked; + + beforeEach(async () => { + entityManager = { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + } as any; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SubscriptionPlanController], + providers: [ + { + provide: SubscriptionPlanService, + useValue: { + createSubscriptionPlan: jest.fn(), + getSubscriptionPlans: jest.fn(), + getSubscriptionPlanById: jest.fn(), + updateSubscriptionPlan: jest.fn(), + deleteSubscriptionPlan: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(SubscriptionPlanController); + service = module.get(SubscriptionPlanService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('createSubscriptionPlan', () => { + it('should create a new subscription plan', async () => { + const createSubscriptionPlanDto: CreateSubscriptionPlanDto = { name: 'Basic Plan', price: 10 } as CreateSubscriptionPlanDto; + const plan = { id: '1', ...createSubscriptionPlanDto } as unknown as SubscriptionPlan; + + jest.spyOn(service, 'createSubscriptionPlan').mockResolvedValue(plan); + + const result = await controller.createSubscriptionPlan(createSubscriptionPlanDto, { transactionManager: entityManager }); + expect(result).toEqual(plan); + expect(service.createSubscriptionPlan).toHaveBeenCalledWith(createSubscriptionPlanDto, entityManager); + }); + }); + + describe('getSubscriptionPlans', () => { + it('should return a list of subscription plans', async () => { + const plans = [{ id: '1', name: 'Basic Plan', price: 10 }] as SubscriptionPlan[]; + + jest.spyOn(service, 'getSubscriptionPlans').mockResolvedValue(plans); + + const result = await controller.getSubscriptionPlans({ transactionManager: entityManager }); + expect(result).toEqual(plans); + expect(service.getSubscriptionPlans).toHaveBeenCalledWith(entityManager); + }); + }); + + describe('getSubscriptionPlanById', () => { + it('should return a subscription plan by ID', async () => { + const plan = { id: '1', name: 'Basic Plan', price: 10 } as SubscriptionPlan; + + jest.spyOn(service, 'getSubscriptionPlanById').mockResolvedValue(plan); + + const result = await controller.getSubscriptionPlanById('1', { transactionManager: entityManager }); + expect(result).toEqual(plan); + expect(service.getSubscriptionPlanById).toHaveBeenCalledWith('1', entityManager); + }); + }); + + describe('updateSubscriptionPlan', () => { + it('should update a subscription plan by ID', async () => { + const updateSubscriptionPlanDto: UpdateSubscriptionPlanDto = { name: 'Pro Plan', price: 20 }; + const plan = { id: '1', ...updateSubscriptionPlanDto } as SubscriptionPlan; + + jest.spyOn(service, 'updateSubscriptionPlan').mockResolvedValue(plan); + + const result = await controller.updateSubscriptionPlan('1', updateSubscriptionPlanDto, { transactionManager: entityManager }); + expect(result).toEqual(plan); + expect(service.updateSubscriptionPlan).toHaveBeenCalledWith('1', updateSubscriptionPlanDto, entityManager); + }); + }); + + describe('deleteSubscriptionPlan', () => { + it('should delete a subscription plan by ID', async () => { + jest.spyOn(service, 'deleteSubscriptionPlan').mockResolvedValue(undefined); + + const result = await controller.deleteSubscriptionPlan('1', { transactionManager: entityManager }); + expect(result).toBeUndefined(); + expect(service.deleteSubscriptionPlan).toHaveBeenCalledWith('1', entityManager); + }); + }); +}); diff --git a/app/tests/controllers/subscription.controller.spec.ts b/app/tests/controllers/subscription.controller.spec.ts index 5b5423f..58561d3 100644 --- a/app/tests/controllers/subscription.controller.spec.ts +++ b/app/tests/controllers/subscription.controller.spec.ts @@ -1,39 +1,43 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { SubscriptionController } from '../../src/controllers/subscription.controller'; -import { SubscriptionService } from '../../src/services/subscription.service'; -import { CreateSubscriptionDto, CreateSubscriptionPlanDto, UpdateSubscriptionStatusDto, UpdateSubscriptionPlanDto } from '../../src/dtos/subscription.dto'; +import { CustomerSubscriptionController } from '../../src/controllers/subscription.controller'; +import { CustomerSubscriptionService } from '../../src/services/subscription.service'; +import { CreateSubscriptionDto, UpdateSubscriptionStatusDto } from '../../src/dtos/subscription.dto'; import { CustomerSubscription } from '../../src/entities/customer.entity'; -import { SubscriptionPlan } from '../../src/entities/subscription.entity'; import { SubscriptionStatus } from '../../src/utils/enums'; import { DataLookup } from '../../src/entities/data-lookup.entity'; import { User } from '../../src/entities/user.entity'; +import { EntityManager } from 'typeorm'; +import { SubscriptionPlan } from '../../src/entities/subscription.entity'; -describe('SubscriptionController', () => { - let controller: SubscriptionController; - let service: SubscriptionService; +describe('CustomerSubscriptionController', () => { + let controller: CustomerSubscriptionController; + let service: CustomerSubscriptionService; + let entityManager: jest.Mocked; beforeEach(async () => { + entityManager = { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + } as any; + const module: TestingModule = await Test.createTestingModule({ - controllers: [SubscriptionController], + controllers: [CustomerSubscriptionController], providers: [ { - provide: SubscriptionService, + provide: CustomerSubscriptionService, useValue: { createCustomerSubscription: jest.fn(), - createSubscriptionPlan: jest.fn(), - getSubscriptionPlans: jest.fn(), getCustomerSubscriptions: jest.fn(), updateSubscriptionStatus: jest.fn(), - getSubscriptionPlanById: jest.fn(), - updateSubscriptionPlan: jest.fn(), - deleteSubscriptionPlan: jest.fn(), }, }, ], }).compile(); - controller = module.get(SubscriptionController); - service = module.get(SubscriptionService); + controller = module.get(CustomerSubscriptionController); + service = module.get(CustomerSubscriptionService); }); it('should be defined', () => { @@ -47,34 +51,9 @@ describe('SubscriptionController', () => { jest.spyOn(service, 'createCustomerSubscription').mockResolvedValue(subscription); - const result = await controller.createCustomerSubscription(createSubscriptionDto); + const result = await controller.createCustomerSubscription(createSubscriptionDto, { transactionManager: entityManager }); expect(result).toEqual(subscription); - expect(service.createCustomerSubscription).toHaveBeenCalledWith(createSubscriptionDto); - }); - }); - - describe('createSubscriptionPlan', () => { - it('should create a new subscription plan', async () => { - const createSubscriptionPlanDto: CreateSubscriptionPlanDto = { name: 'Basic Plan', price: 10 } as CreateSubscriptionPlanDto; - const plan = { id: '1', ...createSubscriptionPlanDto } as unknown as SubscriptionPlan; - - jest.spyOn(service, 'createSubscriptionPlan').mockResolvedValue(plan); - - const result = await controller.createSubscriptionPlan(createSubscriptionPlanDto); - expect(result).toEqual(plan); - expect(service.createSubscriptionPlan).toHaveBeenCalledWith(createSubscriptionPlanDto); - }); - }); - - describe('plans', () => { - it('should return a list of subscription plans', async () => { - const plans = [{ id: '1', name: 'Basic Plan', price: 10 }] as SubscriptionPlan[]; - - jest.spyOn(service, 'getSubscriptionPlans').mockResolvedValue(plans); - - const result = await controller.plans(); - expect(result).toEqual(plans); - expect(service.getSubscriptionPlans).toHaveBeenCalled(); + expect(service.createCustomerSubscription).toHaveBeenCalledWith(createSubscriptionDto, entityManager); }); }); @@ -84,57 +63,27 @@ describe('SubscriptionController', () => { jest.spyOn(service, 'getCustomerSubscriptions').mockResolvedValue(subscriptions); - const result = await controller.getCustomerSubscriptions('1'); + const result = await controller.getCustomerSubscriptions('1', { transactionManager: entityManager }); expect(result).toEqual(subscriptions); - expect(service.getCustomerSubscriptions).toHaveBeenCalledWith('1'); + expect(service.getCustomerSubscriptions).toHaveBeenCalledWith('1', entityManager); }); }); describe('updateSubscriptionStatus', () => { it('should update the status of a subscription', async () => { const updateSubscriptionStatusDto: UpdateSubscriptionStatusDto = { subscriptionStatusId: SubscriptionStatus.ACTIVE }; - const subscription = { id: '1', user: { id: '1' } as User, subscriptionPlan: { id: 'plan1' } as SubscriptionPlan, status: { id: "slkdjf", value: SubscriptionStatus.PENDING } as DataLookup } as unknown as CustomerSubscription; + const subscription = { + id: '1', + user: { id: '1' } as User, + subscriptionPlan: { id: 'plan1' } as SubscriptionPlan, + status: { id: "slkdjf", value: SubscriptionStatus.PENDING } as DataLookup, + } as unknown as CustomerSubscription; jest.spyOn(service, 'updateSubscriptionStatus').mockResolvedValue(subscription); - const result = await controller.updateSubscriptionStatus('1', updateSubscriptionStatusDto); + const result = await controller.updateSubscriptionStatus('1', updateSubscriptionStatusDto, { transactionManager: entityManager }); expect(result).toEqual(subscription); - expect(service.updateSubscriptionStatus).toHaveBeenCalledWith('1', updateSubscriptionStatusDto); - }); - }); - - describe('getSubscriptionPlanById', () => { - it('should return a subscription plan by ID', async () => { - const plan = { id: '1', name: 'Basic Plan', price: 10 } as SubscriptionPlan; - - jest.spyOn(service, 'getSubscriptionPlanById').mockResolvedValue(plan); - - const result = await controller.getSubscriptionPlanById('1'); - expect(result).toEqual(plan); - expect(service.getSubscriptionPlanById).toHaveBeenCalledWith('1'); - }); - }); - - describe('updateSubscriptionPlan', () => { - it('should update a subscription plan by ID', async () => { - const updateSubscriptionPlanDto: UpdateSubscriptionPlanDto = { name: 'Pro Plan', price: 20 }; - const plan = { id: '1', ...updateSubscriptionPlanDto } as SubscriptionPlan; - - jest.spyOn(service, 'updateSubscriptionPlan').mockResolvedValue(plan); - - const result = await controller.updateSubscriptionPlan('1', updateSubscriptionPlanDto); - expect(result).toEqual(plan); - expect(service.updateSubscriptionPlan).toHaveBeenCalledWith('1', updateSubscriptionPlanDto); - }); - }); - - describe('deleteSubscriptionPlan', () => { - it('should delete a subscription plan by ID', async () => { - jest.spyOn(service, 'deleteSubscriptionPlan').mockResolvedValue(undefined); - - const result = await controller.deleteSubscriptionPlan('1'); - expect(result).toBeUndefined(); - expect(service.deleteSubscriptionPlan).toHaveBeenCalledWith('1'); + expect(service.updateSubscriptionStatus).toHaveBeenCalledWith('1', updateSubscriptionStatusDto, entityManager); }); }); }); diff --git a/app/tests/services/base.service.spec.ts b/app/tests/services/base.service.spec.ts index 0b0a28a..0a77049 100644 --- a/app/tests/services/base.service.spec.ts +++ b/app/tests/services/base.service.spec.ts @@ -14,7 +14,6 @@ describe('GenericService', () => { let service: GenericService; let dataSourceMock: jest.Mocked; let repositoryMock: jest.Mocked>; - let queryRunnerMock: jest.Mocked; let entityManagerMock: jest.Mocked; beforeEach(async () => { @@ -27,18 +26,8 @@ describe('GenericService', () => { save: jest.fn(), } as unknown as jest.Mocked; - queryRunnerMock = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager: entityManagerMock, // Assign the mocked EntityManager - } as unknown as jest.Mocked; - dataSourceMock = { getRepository: jest.fn().mockReturnValue(repositoryMock), // Mocking getRepository here - createQueryRunner: jest.fn().mockReturnValue(queryRunnerMock), } as unknown as jest.Mocked; const module: TestingModule = await Test.createTestingModule({ @@ -61,29 +50,26 @@ describe('GenericService', () => { it('should throw NotFoundException if entity is not found', async () => { entityManagerMock.findOne.mockResolvedValue(null); - await expect(service.destroy('test-id')).rejects.toThrow( + await expect(service.destroy('test-id', entityManagerMock)).rejects.toThrow( new NotFoundException('TestEntity with id test-id not found'), ); - expect(queryRunnerMock.connect).toHaveBeenCalled(); - expect(queryRunnerMock.startTransaction).toHaveBeenCalled(); - expect(queryRunnerMock.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunnerMock.release).toHaveBeenCalled(); + expect(entityManagerMock.findOne).toHaveBeenCalledWith(TestEntity, { where: { id: 'test-id' } }); }); it('should throw NotFoundException if deleted state is not found', async () => { entityManagerMock.findOne - .mockResolvedValueOnce({} as TestEntity) - .mockResolvedValueOnce(null); + .mockResolvedValueOnce({} as TestEntity) // Entity found + .mockResolvedValueOnce(null); // Deleted state not found - await expect(service.destroy('test-id')).rejects.toThrow( + await expect(service.destroy('test-id', entityManagerMock)).rejects.toThrow( new NotFoundException('Deleted state not found in DataLookup'), ); - expect(queryRunnerMock.connect).toHaveBeenCalled(); - expect(queryRunnerMock.startTransaction).toHaveBeenCalled(); - expect(queryRunnerMock.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunnerMock.release).toHaveBeenCalled(); + expect(entityManagerMock.findOne).toHaveBeenCalledWith(TestEntity, { where: { id: 'test-id' } }); + expect(entityManagerMock.findOne).toHaveBeenCalledWith(DataLookup, { + where: { value: ObjectState.DELETED }, + }); }); it('should set entity state to DELETED and save it', async () => { @@ -91,52 +77,13 @@ describe('GenericService', () => { const deletedState = { value: ObjectState.DELETED } as DataLookup; entityManagerMock.findOne - .mockResolvedValueOnce(entity) - .mockResolvedValueOnce(deletedState); + .mockResolvedValueOnce(entity) // Entity found + .mockResolvedValueOnce(deletedState); // Deleted state found - await service.destroy('test-id'); + await service.destroy('test-id', entityManagerMock); expect(entity.objectState).toBe(deletedState); expect(entityManagerMock.save).toHaveBeenCalledWith(entity); - expect(queryRunnerMock.commitTransaction).toHaveBeenCalled(); - expect(queryRunnerMock.release).toHaveBeenCalled(); - }); - }); - - describe('saveEntityWithDefaultState', () => { - it('should throw NotFoundException if default state is not found', async () => { - dataSourceMock.getRepository = jest.fn().mockReturnValue({ - findOne: jest.fn().mockResolvedValue(null), - } as unknown as jest.Mocked>); - - const entity = new TestEntity(); - - await expect(service.saveEntityWithDefaultState(entity, 'test-type')).rejects.toThrow( - new NotFoundException('Unable to find default state for type test-type, please seed fixture data.'), - ); - }); - - it('should save entity with default state if it is a new entity', async () => { - const defaultState = { type: 'test-type', is_default: true } as DataLookup; - dataSourceMock.getRepository = jest.fn().mockReturnValue({ - findOne: jest.fn().mockResolvedValue(defaultState), - } as unknown as jest.Mocked>); - - const entity = new TestEntity(); - - await service.saveEntityWithDefaultState(entity, 'test-type'); - - expect(entity.objectState).toBe(defaultState); - expect(repositoryMock.save).toHaveBeenCalledWith(entity); - }); - - it('should save entity without changing state if it is not new', async () => { - const entity = new TestEntity(); - entity.id = 'existing-id'; - - await service.saveEntityWithDefaultState(entity, 'test-type'); - - expect(repositoryMock.save).toHaveBeenCalledWith(entity); }); }); -}); \ No newline at end of file +}); diff --git a/app/tests/services/data-lookup.service.spec.ts b/app/tests/services/data-lookup.service.spec.ts index cb1ba54..4bd7e58 100644 --- a/app/tests/services/data-lookup.service.spec.ts +++ b/app/tests/services/data-lookup.service.spec.ts @@ -1,68 +1,85 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { NotFoundException } from '@nestjs/common'; -import { Repository } from 'typeorm'; +import { Repository, EntityManager } from 'typeorm'; import { DataLookupService } from '../../src/services/data-lookup.service'; import { DataLookup } from '../../src/entities/data-lookup.entity'; -import { CreateDataLookupDto } from 'src/dtos/core.dto'; +import { CreateDataLookupDto } from '../../src/dtos/core.dto'; describe('DataLookupService', () => { let service: DataLookupService; let repository: jest.Mocked>; + let entityManager: jest.Mocked; beforeEach(async () => { + repository = { + count: jest.fn(), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOneBy: jest.fn(), + } as unknown as jest.Mocked>; + + entityManager = { + count: jest.fn(), + findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + } as unknown as jest.Mocked; + const module: TestingModule = await Test.createTestingModule({ providers: [ DataLookupService, { provide: getRepositoryToken(DataLookup), - useValue: { - count: jest.fn(), - create: jest.fn(), - save: jest.fn(), - find: jest.fn(), - findOneBy: jest.fn(), - }, + useValue: repository, + }, + { + provide: EntityManager, + useValue: entityManager, }, ], }).compile(); service = module.get(DataLookupService); - repository = module.get>>(getRepositoryToken(DataLookup)); }); describe('existsByValue', () => { it('should return true if the value exists', async () => { - repository.count.mockResolvedValue(1); + entityManager.count.mockResolvedValue(1); - const result = await service.existsByValue('some-value'); + const result = await service.existsByValue('some-value', entityManager); expect(result).toBe(true); - expect(repository.count).toHaveBeenCalledWith({ where: { value: 'some-value' } }); + expect(entityManager.count).toHaveBeenCalledWith(DataLookup, { where: { value: 'some-value' } }); }); it('should return false if the value does not exist', async () => { - repository.count.mockResolvedValue(0); + entityManager.count.mockResolvedValue(0); - const result = await service.existsByValue('some-value'); + const result = await service.existsByValue('some-value', entityManager); expect(result).toBe(false); - expect(repository.count).toHaveBeenCalledWith({ where: { value: 'some-value' } }); + expect(entityManager.count).toHaveBeenCalledWith(DataLookup, { where: { value: 'some-value' } }); }); }); + describe('create', () => { it('should create and save a DataLookup entity', async () => { const createDto: CreateDataLookupDto = { value: 'some-value', type: 'some-type' }; const mockEntity = { id: '1', ...createDto } as DataLookup; - repository.create.mockReturnValue(mockEntity); - repository.save.mockResolvedValue(mockEntity); + entityManager.create.mockReturnValue(mockEntity as any); + entityManager.save.mockResolvedValue(mockEntity); - const result = await service.create(createDto); + const result = await service.create(createDto, entityManager); - expect(repository.create).toHaveBeenCalledWith(createDto); - expect(repository.save).toHaveBeenCalledWith(mockEntity); + expect(entityManager.create).toHaveBeenCalledWith(DataLookup, createDto); + expect(entityManager.save).toHaveBeenCalledWith(DataLookup, mockEntity); expect(result).toBe(mockEntity); }); }); @@ -75,47 +92,53 @@ describe('DataLookupService', () => { ]; const mockEntities = createDtos.map((dto, index) => ({ id: String(index + 1), ...dto } as DataLookup)); - repository.create.mockImplementation(dto => mockEntities.find(e => e.value === dto.value)); - repository.save.mockImplementation(entity => Promise.resolve(entity as DataLookup)); + createDtos.forEach((dto, index) => { + entityManager.create.mockReturnValueOnce(mockEntities[index] as any); + }); + + entityManager.save.mockResolvedValue(mockEntities); - const result = await service.createBulk(createDtos); + const result = await service.createBulk(createDtos, entityManager); expect(result).toEqual(mockEntities); - expect(repository.create).toHaveBeenCalledTimes(createDtos.length); - expect(repository.save).toHaveBeenCalledTimes(1); + expect(entityManager.create).toHaveBeenCalledTimes(createDtos.length); + createDtos.forEach(dto => { + expect(entityManager.create).toHaveBeenCalledWith(DataLookup, dto); + }); + expect(entityManager.save).toHaveBeenCalledWith(DataLookup, mockEntities); }); }); describe('findAll', () => { it('should return an array of DataLookup entities', async () => { const mockEntities = [{ id: '1', value: 'value1', type: 'type1' } as DataLookup]; - repository.find.mockResolvedValue(mockEntities); + entityManager.find.mockResolvedValue(mockEntities); - const result = await service.findAll(); + const result = await service.findAll(entityManager); expect(result).toBe(mockEntities); - expect(repository.find).toHaveBeenCalled(); + expect(entityManager.find).toHaveBeenCalledWith(DataLookup); }); }); describe('findOne', () => { it('should return a DataLookup entity if found', async () => { const mockEntity = { id: '1', value: 'value1', type: 'type1' } as DataLookup; - repository.findOneBy.mockResolvedValue(mockEntity); + entityManager.findOneBy.mockResolvedValue(mockEntity); - const result = await service.findOne('1'); + const result = await service.findOne('1', entityManager); expect(result).toBe(mockEntity); - expect(repository.findOneBy).toHaveBeenCalledWith({ id: '1' }); + expect(entityManager.findOneBy).toHaveBeenCalledWith(DataLookup, { id: '1' }); }); it('should throw NotFoundException if the entity is not found', async () => { - repository.findOneBy.mockResolvedValue(null); + entityManager.findOneBy.mockResolvedValue(null); - await expect(service.findOne('1')).rejects.toThrow( + await expect(service.findOne('1', entityManager)).rejects.toThrow( new NotFoundException(`Data Lookup with ID 1 not found`), ); - expect(repository.findOneBy).toHaveBeenCalledWith({ id: '1' }); + expect(entityManager.findOneBy).toHaveBeenCalledWith(DataLookup, { id: '1' }); }); }); }); diff --git a/app/tests/services/setting.service.spec.ts b/app/tests/services/setting.service.spec.ts index 1632a5a..2e98322 100644 --- a/app/tests/services/setting.service.spec.ts +++ b/app/tests/services/setting.service.spec.ts @@ -1,20 +1,21 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, DataSource, EntityManager } from 'typeorm'; import { SystemSettingService } from '../../src/services/setting.service'; import { SystemSetting } from '../../src/entities/system-settings.entity'; import { CreateSystemSettingDto, UpdateSystemSettingDto } from '../../src/dtos/settings.dto'; import { NotFoundException, BadRequestException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; jest.mock('../../src/services/base.service'); describe('SystemSettingService', () => { let service: SystemSettingService; let repositoryMock: jest.Mocked>; + let entityManagerMock: jest.Mocked; let dataSourceMock: jest.Mocked; beforeEach(async () => { + repositoryMock = { create: jest.fn(), save: jest.fn(), @@ -22,6 +23,14 @@ describe('SystemSettingService', () => { findOne: jest.fn(), } as unknown as jest.Mocked>; + entityManagerMock = { + findOne: jest.fn(), + save: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + create: jest.fn(), + } as unknown as jest.Mocked; + dataSourceMock = { getRepository: jest.fn().mockReturnValue(repositoryMock), } as unknown as jest.Mocked; @@ -33,6 +42,10 @@ describe('SystemSettingService', () => { provide: getRepositoryToken(SystemSetting), useValue: repositoryMock, }, + { + provide: EntityManager, + useValue: entityManagerMock, + }, { provide: DataSource, useValue: dataSourceMock, @@ -49,12 +62,12 @@ describe('SystemSettingService', () => { const mockSetting = { id: '1', ...createDto, currentValue: 'default1' } as SystemSetting; repositoryMock.create.mockReturnValue(mockSetting); - repositoryMock.save.mockResolvedValue(mockSetting); + entityManagerMock.save.mockResolvedValue(mockSetting); - const result = await service.create(createDto); + const result = await service.create(createDto, entityManagerMock); expect(repositoryMock.create).toHaveBeenCalledWith({ ...createDto, currentValue: 'default1' }); - expect(repositoryMock.save).toHaveBeenCalledWith(mockSetting); + expect(entityManagerMock.save).toHaveBeenCalledWith(SystemSetting, mockSetting); expect(result).toBe(mockSetting); }); }); @@ -62,30 +75,30 @@ describe('SystemSettingService', () => { describe('findAll', () => { it('should return an array of system settings', async () => { const mockSettings = [{ id: '1', code: 'key1', currentValue: 'value1' }] as SystemSetting[]; - repositoryMock.find.mockResolvedValue(mockSettings); + entityManagerMock.find.mockResolvedValue(mockSettings); // Adjusted to mock EntityManager - const result = await service.findAll(); + const result = await service.findAll(entityManagerMock); expect(result).toBe(mockSettings); - expect(repositoryMock.find).toHaveBeenCalled(); + expect(entityManagerMock.find).toHaveBeenCalledWith(SystemSetting); }); - }); + }); describe('findOne', () => { it('should return a system setting if found', async () => { const mockSetting = { id: '1', code: 'key1', currentValue: 'value1' } as SystemSetting; - repositoryMock.findOne.mockResolvedValue(mockSetting); + entityManagerMock.findOne.mockResolvedValue(mockSetting); - const result = await service.findOne('1'); + const result = await service.findOne('1', entityManagerMock); expect(result).toBe(mockSetting); - expect(repositoryMock.findOne).toHaveBeenCalledWith({ where: { id: '1' } }); + expect(entityManagerMock.findOne).toHaveBeenCalledWith(SystemSetting, { where: { id: '1' } }); }); it('should throw NotFoundException if the system setting is not found', async () => { - repositoryMock.findOne.mockResolvedValue(null); + entityManagerMock.findOne.mockResolvedValue(null); - await expect(service.findOne('1')).rejects.toThrow(new NotFoundException(`SystemSetting with ID 1 not found`)); + await expect(service.findOne('1', entityManagerMock)).rejects.toThrow(new NotFoundException(`SystemSetting with ID 1 not found`)); }); }); @@ -94,19 +107,19 @@ describe('SystemSettingService', () => { const mockSetting = { id: '1', code: 'key1', currentValue: 'value1' } as SystemSetting; const updateDto: UpdateSystemSettingDto = { currentValue: 'newValue' }; - repositoryMock.findOne.mockResolvedValue(mockSetting); - repositoryMock.save.mockResolvedValue({ ...mockSetting, ...updateDto } as SystemSetting); + entityManagerMock.findOne.mockResolvedValue(mockSetting); + entityManagerMock.save.mockResolvedValue({ ...mockSetting, ...updateDto } as SystemSetting); - const result = await service.update('1', updateDto); + const result = await service.update('1', updateDto, entityManagerMock); expect(result).toEqual({ ...mockSetting, ...updateDto }); - expect(repositoryMock.save).toHaveBeenCalledWith({ ...mockSetting, ...updateDto }); + expect(entityManagerMock.save).toHaveBeenCalledWith(SystemSetting, { ...mockSetting, ...updateDto }); }); it('should throw NotFoundException if the system setting is not found', async () => { - repositoryMock.findOne.mockResolvedValue(null); + entityManagerMock.findOne.mockResolvedValue(null); - await expect(service.update('1', {} as UpdateSystemSettingDto)).rejects.toThrow( + await expect(service.update('1', {} as UpdateSystemSettingDto, entityManagerMock)).rejects.toThrow( new NotFoundException(`SystemSetting with ID 1 not found`), ); }); @@ -114,11 +127,11 @@ describe('SystemSettingService', () => { describe('remove', () => { it('should remove the system setting using destroy', async () => { - const destroySpy = jest.spyOn(service, 'destroy').mockResolvedValue(); + entityManagerMock.delete.mockResolvedValue(undefined); // Mock the delete method - await service.remove('1'); + await service.remove('1', entityManagerMock); - expect(destroySpy).toHaveBeenCalledWith('1'); + expect(entityManagerMock.delete).toHaveBeenCalledWith(SystemSetting, '1'); }); }); @@ -126,19 +139,19 @@ describe('SystemSettingService', () => { it('should reset the currentValue to the defaultValue if they differ', async () => { const mockSetting = { id: '1', code: 'code1', currentValue: 'oldValue', defaultValue: 'defaultValue' } as SystemSetting; - repositoryMock.findOne.mockResolvedValue(mockSetting); - repositoryMock.save.mockResolvedValue({ ...mockSetting, currentValue: 'defaultValue' } as SystemSetting); + entityManagerMock.findOne.mockResolvedValue(mockSetting); + entityManagerMock.save.mockResolvedValue({ ...mockSetting, currentValue: 'defaultValue' } as SystemSetting); - const result = await service.resetSetting('code1'); + const result = await service.resetSetting('code1', entityManagerMock); expect(result.currentValue).toBe('defaultValue'); - expect(repositoryMock.save).toHaveBeenCalledWith({ ...mockSetting, currentValue: 'defaultValue' }); + expect(entityManagerMock.save).toHaveBeenCalledWith(SystemSetting, { ...mockSetting, currentValue: 'defaultValue' }); }); it('should throw NotFoundException if the setting with the given code is not found', async () => { - repositoryMock.findOne.mockResolvedValue(null); + entityManagerMock.findOne.mockResolvedValue(null); - await expect(service.resetSetting('code1')).rejects.toThrow( + await expect(service.resetSetting('code1', entityManagerMock)).rejects.toThrow( new NotFoundException(`SystemSetting with code code1 not found`), ); }); @@ -146,9 +159,9 @@ describe('SystemSettingService', () => { it('should throw BadRequestException if currentValue is already the same as defaultValue', async () => { const mockSetting = { id: '1', code: 'code1', currentValue: 'defaultValue', defaultValue: 'defaultValue' } as SystemSetting; - repositoryMock.findOne.mockResolvedValue(mockSetting); + entityManagerMock.findOne.mockResolvedValue(mockSetting); - await expect(service.resetSetting('code1')).rejects.toThrow( + await expect(service.resetSetting('code1', entityManagerMock)).rejects.toThrow( new BadRequestException(`Current value is already the same as the default value`), ); }); diff --git a/app/tests/services/subscription-plan.service.spec.ts b/app/tests/services/subscription-plan.service.spec.ts new file mode 100644 index 0000000..7ed7693 --- /dev/null +++ b/app/tests/services/subscription-plan.service.spec.ts @@ -0,0 +1,201 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionPlanService } from '../../src/services/subscription-plan.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, EntityManager, DataSource } from 'typeorm'; +import { SubscriptionPlan } from '../../src/entities/subscription.entity'; +import { DataLookup } from '../../src/entities/data-lookup.entity'; +import { NotFoundException } from '@nestjs/common'; +import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../../src/dtos/subscription.dto'; + +jest.mock('../../src/services/base.service'); + +describe('SubscriptionPlanService', () => { + let service: SubscriptionPlanService; + let subscriptionPlanRepository: jest.Mocked>; + let dataLookupRepository: jest.Mocked>; + let entityManager: jest.Mocked; + let dataSource: jest.Mocked; + + beforeEach(async () => { + dataSource = { + manager: entityManager, + } as unknown as jest.Mocked; + + entityManager = { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SubscriptionPlanService, + { + provide: getRepositoryToken(SubscriptionPlan), + useValue: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + }, + }, + { + provide: getRepositoryToken(DataLookup), + useValue: { + findOneBy: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: EntityManager, + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: DataSource, + useValue: dataSource, + }, + ], + }).compile(); + + service = module.get(SubscriptionPlanService); + subscriptionPlanRepository = module.get(getRepositoryToken(SubscriptionPlan)); + dataLookupRepository = module.get(getRepositoryToken(DataLookup)); + entityManager = module.get(EntityManager); + }); + + describe('createSubscriptionPlan', () => { + it('should create a new subscription plan', async () => { + const createSubscriptionPlanDto: CreateSubscriptionPlanDto = { + name: 'Plan A', + description: 'Basic Plan', + price: 100, + billingCycleDays: 30, + prorate: true, + } as CreateSubscriptionPlanDto; + + const mockPlanState = { id: 'state_1' } as DataLookup; + const mockPlan = { id: 'plan_1' } as SubscriptionPlan; + + entityManager.findOne.mockResolvedValueOnce(mockPlanState); // PlanState found + subscriptionPlanRepository.create.mockReturnValue(mockPlan); + entityManager.save.mockResolvedValueOnce(mockPlan) + + const result = await service.createSubscriptionPlan(createSubscriptionPlanDto, entityManager); + + expect(result).toEqual(mockPlan); + expect(subscriptionPlanRepository.create).toHaveBeenCalledWith({ + ...createSubscriptionPlanDto, + status: mockPlanState, + }); + expect(entityManager.save).toHaveBeenCalledWith(SubscriptionPlan, mockPlan); + }); + + it('should throw NotFoundException if default state is not found', async () => { + const createSubscriptionPlanDto: CreateSubscriptionPlanDto = { + name: 'Plan A', + description: 'Basic Plan', + price: 100, + billingCycleDays: 30, + prorate: true, + } as CreateSubscriptionPlanDto; + + entityManager.findOne.mockResolvedValueOnce(null); // PlanState not found + + await expect(service.createSubscriptionPlan(createSubscriptionPlanDto, entityManager)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSubscriptionPlans', () => { + it('should return subscription plans', async () => { + const mockPlans = [{ id: 'plan_1' }] as SubscriptionPlan[]; + + entityManager.find.mockResolvedValueOnce(mockPlans); // Plans found + + const result = await service.getSubscriptionPlans(entityManager); + + expect(result).toEqual(mockPlans); + expect(entityManager.find).toHaveBeenCalledWith(SubscriptionPlan, { + relations: ['status', 'objectState'], + }); + }); + }); + + describe('getSubscriptionPlanById', () => { + it('should return a subscription plan by id', async () => { + const planId = 'plan_1'; + const mockPlan = { id: 'plan_1' } as SubscriptionPlan; + + entityManager.findOne.mockResolvedValueOnce(mockPlan); // Plan found + + const result = await service.getSubscriptionPlanById(planId, entityManager); + + expect(result).toEqual(mockPlan); + expect(entityManager.findOne).toHaveBeenCalledWith(SubscriptionPlan, { + where: { id: planId }, + relations: ['status', 'objectState'], + }); + }); + + it('should throw NotFoundException if subscription plan is not found', async () => { + const planId = 'plan_1'; + + entityManager.findOne.mockResolvedValueOnce(null); // Plan not found + + await expect(service.getSubscriptionPlanById(planId, entityManager)).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateSubscriptionPlan', () => { + it('should update a subscription plan', async () => { + const planId = 'plan_1'; + const updateSubscriptionPlanDto: UpdateSubscriptionPlanDto = { + name: 'Updated Plan', + description: 'Updated Description', + price: 200, + billingCycleDays: 45, + statusId: 'status_2', + prorate: false, + }; + + const mockPlan = { id: 'plan_1' } as SubscriptionPlan; + const mockStatus = { id: 'status_2' } as DataLookup; + + service.getSubscriptionPlanById = jest.fn().mockResolvedValue(mockPlan); + entityManager.findOne.mockResolvedValueOnce(mockStatus); // Status found + entityManager.save.mockResolvedValueOnce(mockPlan); // Save the updated plan + + const result = await service.updateSubscriptionPlan(planId, updateSubscriptionPlanDto, entityManager); + + expect(result).toEqual(mockPlan); + expect(service.getSubscriptionPlanById).toHaveBeenCalledWith(planId, entityManager); + expect(entityManager.findOne).toHaveBeenCalledWith(DataLookup, { where: { id: updateSubscriptionPlanDto.statusId } }); + expect(entityManager.save).toHaveBeenCalledWith(SubscriptionPlan, mockPlan); + }); + + it('should throw NotFoundException if status is not found', async () => { + const planId = 'plan_1'; + const updateSubscriptionPlanDto: UpdateSubscriptionPlanDto = { + name: 'Updated Plan', + description: 'Updated Description', + price: 200, + billingCycleDays: 45, + statusId: 'status_2', + prorate: false, + }; + + const mockPlan = { id: 'plan_1' } as SubscriptionPlan; + + service.getSubscriptionPlanById = jest.fn().mockResolvedValue(mockPlan); + entityManager.findOne.mockResolvedValueOnce(null); // Status not found + + await expect(service.updateSubscriptionPlan(planId, updateSubscriptionPlanDto, entityManager)).rejects.toThrow(NotFoundException); + }); + }); + +}); diff --git a/app/tests/services/subscription.service.spec.ts b/app/tests/services/subscription.service.spec.ts index 497ea7f..24269cc 100644 --- a/app/tests/services/subscription.service.spec.ts +++ b/app/tests/services/subscription.service.spec.ts @@ -1,74 +1,93 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { SubscriptionService } from '../../src/services/subscription.service'; +import { CustomerSubscriptionService } from '../../src/services/subscription.service'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository, DataSource } from 'typeorm'; +import { Repository, EntityManager, DataSource } from 'typeorm'; import { CustomerSubscription } from '../../src/entities/customer.entity'; import { User } from '../../src/entities/user.entity'; import { SubscriptionPlan } from '../../src/entities/subscription.entity'; import { DataLookup } from '../../src/entities/data-lookup.entity'; import { NotFoundException } from '@nestjs/common'; -import { CreateSubscriptionDto, CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto, UpdateSubscriptionStatusDto } from '../../src/dtos/subscription.dto'; +import { CreateSubscriptionDto, UpdateSubscriptionStatusDto } from '../../src/dtos/subscription.dto'; jest.mock('../../src/services/base.service'); -describe('SubscriptionService', () => { - let service: SubscriptionService; +describe('CustomerSubscriptionService', () => { + let service: CustomerSubscriptionService; let customerSubscriptionRepository: jest.Mocked>; let userRepository: jest.Mocked>; let subscriptionPlanRepository: jest.Mocked>; let dataLookupRepository: jest.Mocked>; - let dataSource: DataSource; + let entityManager: jest.Mocked; + let dataSourceMock: jest.Mocked; beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-08-24T23:05:09.009Z')); + customerSubscriptionRepository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + } as unknown as jest.Mocked>; + + userRepository = { + findOneBy: jest.fn(), + } as unknown as jest.Mocked>; + + subscriptionPlanRepository = { + findOneBy: jest.fn(), + } as unknown as jest.Mocked>; + + dataLookupRepository = { + findOneBy: jest.fn(), + findOne: jest.fn(), + } as unknown as jest.Mocked>; + + entityManager = { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + } as unknown as jest.Mocked; + + dataSourceMock = { + createEntityManager: jest.fn().mockReturnValue(entityManager), + } as unknown as jest.Mocked; const module: TestingModule = await Test.createTestingModule({ providers: [ - SubscriptionService, + CustomerSubscriptionService, { provide: getRepositoryToken(CustomerSubscription), - useValue: { - findOne: jest.fn(), - findOneBy: jest.fn(), - create: jest.fn(), - save: jest.fn(), - find: jest.fn(), - }, + useValue: customerSubscriptionRepository, }, { provide: getRepositoryToken(User), - useValue: { - findOneBy: jest.fn(), - }, + useValue: userRepository, }, { provide: getRepositoryToken(SubscriptionPlan), - useValue: { - findOne: jest.fn(), - findOneBy: jest.fn(), - create: jest.fn(), - save: jest.fn(), - find: jest.fn(), - }, + useValue: subscriptionPlanRepository, }, { provide: getRepositoryToken(DataLookup), - useValue: { - findOneBy: jest.fn(), - findOne: jest.fn(), - }, + useValue: dataLookupRepository, }, { provide: DataSource, - useValue: {}, + useValue: dataSourceMock, }, ], }).compile(); - service = module.get(SubscriptionService); + service = module.get(CustomerSubscriptionService); customerSubscriptionRepository = module.get(getRepositoryToken(CustomerSubscription)); userRepository = module.get(getRepositoryToken(User)); subscriptionPlanRepository = module.get(getRepositoryToken(SubscriptionPlan)); dataLookupRepository = module.get(getRepositoryToken(DataLookup)); - dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.useRealTimers(); }); describe('createCustomerSubscription', () => { @@ -83,24 +102,23 @@ describe('SubscriptionService', () => { const mockStatus = { id: 'status_1' } as DataLookup; const mockSubscription = { id: 'sub_1' } as CustomerSubscription; - userRepository.findOneBy.mockResolvedValue(mockUser); - subscriptionPlanRepository.findOneBy.mockResolvedValue(mockPlan); - dataLookupRepository.findOneBy.mockResolvedValue(mockStatus); + entityManager.findOne.mockResolvedValueOnce(mockUser); // First call for User + entityManager.findOne.mockResolvedValueOnce(mockPlan); // Second call for SubscriptionPlan + entityManager.findOne.mockResolvedValueOnce(mockStatus); // Third call for DataLookup customerSubscriptionRepository.create.mockReturnValue(mockSubscription); - customerSubscriptionRepository.save.mockResolvedValue(mockSubscription); + entityManager.save.mockResolvedValue(mockSubscription); - const result = await service.createCustomerSubscription(createSubscriptionDto); + const result = await service.createCustomerSubscription(createSubscriptionDto, entityManager); expect(result).toEqual(mockSubscription); expect(customerSubscriptionRepository.create).toHaveBeenCalledWith({ user: mockUser, subscriptionPlan: mockPlan, subscriptionStatus: mockStatus, - endDate: null, - nextBillingDate: expect.any(Number), - startDate: expect.any(Number), + startDate: new Date('2024-08-24T23:05:09.009Z'), + nextBillingDate: new Date('2024-09-23T23:05:09.009Z'), }); - expect(customerSubscriptionRepository.save).toHaveBeenCalledWith(mockSubscription); + expect(entityManager.save).toHaveBeenCalledWith(CustomerSubscription, mockSubscription); }); it('should throw NotFoundException if user is not found', async () => { @@ -109,9 +127,9 @@ describe('SubscriptionService', () => { subscriptionPlanId: 'plan_1', }; - userRepository.findOneBy.mockResolvedValue(null); + entityManager.findOne.mockResolvedValueOnce(null); // User not found - await expect(service.createCustomerSubscription(createSubscriptionDto)).rejects.toThrow(NotFoundException); + await expect(service.createCustomerSubscription(createSubscriptionDto, entityManager)).rejects.toThrow(NotFoundException); }); it('should throw NotFoundException if subscription plan is not found', async () => { @@ -121,10 +139,10 @@ describe('SubscriptionService', () => { }; const mockUser = { id: 'user_1' } as User; - userRepository.findOneBy.mockResolvedValue(mockUser); - subscriptionPlanRepository.findOneBy.mockResolvedValue(null); + entityManager.findOne.mockResolvedValueOnce(mockUser); // User found + entityManager.findOne.mockResolvedValueOnce(null); // SubscriptionPlan not found - await expect(service.createCustomerSubscription(createSubscriptionDto)).rejects.toThrow(NotFoundException); + await expect(service.createCustomerSubscription(createSubscriptionDto, entityManager)).rejects.toThrow(NotFoundException); }); it('should throw NotFoundException if subscription status is not found', async () => { @@ -135,11 +153,11 @@ describe('SubscriptionService', () => { const mockUser = { id: 'user_1' } as User; const mockPlan = { id: 'plan_1', billingCycleDays: 30 } as SubscriptionPlan; - userRepository.findOneBy.mockResolvedValue(mockUser); - subscriptionPlanRepository.findOneBy.mockResolvedValue(mockPlan); - dataLookupRepository.findOneBy.mockResolvedValue(null); + entityManager.findOne.mockResolvedValueOnce(mockUser); // User found + entityManager.findOne.mockResolvedValueOnce(mockPlan); // SubscriptionPlan found + entityManager.findOne.mockResolvedValueOnce(null); // DataLookup not found - await expect(service.createCustomerSubscription(createSubscriptionDto)).rejects.toThrow(NotFoundException); + await expect(service.createCustomerSubscription(createSubscriptionDto, entityManager)).rejects.toThrow(NotFoundException); }); }); @@ -149,13 +167,13 @@ describe('SubscriptionService', () => { const mockUser = { id: 'user_1' } as User; const mockSubscriptions = [{ id: 'sub_1' }] as CustomerSubscription[]; - userRepository.findOneBy.mockResolvedValue(mockUser); - customerSubscriptionRepository.find.mockResolvedValue(mockSubscriptions); + entityManager.findOne.mockResolvedValueOnce(mockUser); // User found + entityManager.find.mockResolvedValueOnce(mockSubscriptions); // Subscriptions found - const result = await service.getCustomerSubscriptions(userId); + const result = await service.getCustomerSubscriptions(userId, entityManager); expect(result).toEqual(mockSubscriptions); - expect(customerSubscriptionRepository.find).toHaveBeenCalledWith({ + expect(entityManager.find).toHaveBeenCalledWith(CustomerSubscription, { where: { user: mockUser }, relations: ['subscriptionPlan', 'subscriptionStatus'], }); @@ -164,9 +182,9 @@ describe('SubscriptionService', () => { it('should throw NotFoundException if user is not found', async () => { const userId = 'user_1'; - userRepository.findOneBy.mockResolvedValue(null); + entityManager.findOne.mockResolvedValueOnce(null); // User not found - await expect(service.getCustomerSubscriptions(userId)).rejects.toThrow(NotFoundException); + await expect(service.getCustomerSubscriptions(userId, entityManager)).rejects.toThrow(NotFoundException); }); }); @@ -181,19 +199,19 @@ describe('SubscriptionService', () => { const mockSubscription = { id: 'sub_1', subscriptionStatus: {} } as CustomerSubscription; const mockStatus = { id: 'status_1' } as DataLookup; - customerSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); - dataLookupRepository.findOneBy.mockResolvedValue(mockStatus); - customerSubscriptionRepository.save.mockResolvedValue(mockSubscription); + entityManager.findOne.mockResolvedValueOnce(mockSubscription); // Subscription found + entityManager.findOne.mockResolvedValueOnce(mockStatus); // SubscriptionStatus found + entityManager.save.mockResolvedValueOnce(mockSubscription); // Save the updated subscription - const result = await service.updateSubscriptionStatus(subscriptionId, updateSubscriptionStatusDto); + const result = await service.updateSubscriptionStatus(subscriptionId, updateSubscriptionStatusDto, entityManager); expect(result).toEqual(mockSubscription); - expect(customerSubscriptionRepository.findOne).toHaveBeenCalledWith({ + expect(entityManager.findOne).toHaveBeenCalledWith(CustomerSubscription, { where: { id: subscriptionId }, relations: ['subscriptionStatus'], }); - expect(dataLookupRepository.findOneBy).toHaveBeenCalledWith({ id: updateSubscriptionStatusDto.subscriptionStatusId }); - expect(customerSubscriptionRepository.save).toHaveBeenCalledWith(mockSubscription); + expect(entityManager.findOne).toHaveBeenCalledWith(DataLookup, { where: { id: updateSubscriptionStatusDto.subscriptionStatusId } }); + expect(entityManager.save).toHaveBeenCalledWith(CustomerSubscription, mockSubscription); }); it('should throw NotFoundException if subscription is not found', async () => { @@ -203,9 +221,9 @@ describe('SubscriptionService', () => { endDate: new Date(), }; - customerSubscriptionRepository.findOne.mockResolvedValue(null); + entityManager.findOne.mockResolvedValueOnce(null); // Subscription not found - await expect(service.updateSubscriptionStatus(subscriptionId, updateSubscriptionStatusDto)).rejects.toThrow(NotFoundException); + await expect(service.updateSubscriptionStatus(subscriptionId, updateSubscriptionStatusDto, entityManager)).rejects.toThrow(NotFoundException); }); it('should throw NotFoundException if subscription status is not found', async () => { @@ -217,149 +235,10 @@ describe('SubscriptionService', () => { const mockSubscription = { id: 'sub_1', subscriptionStatus: {} } as CustomerSubscription; - customerSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); - dataLookupRepository.findOneBy.mockResolvedValue(null); - - await expect(service.updateSubscriptionStatus(subscriptionId, updateSubscriptionStatusDto)).rejects.toThrow(NotFoundException); - }); - }); - - describe('createSubscriptionPlan', () => { - it('should create a new subscription plan', async () => { - const createSubscriptionPlanDto: CreateSubscriptionPlanDto = { - name: 'Plan A', - description: 'Basic Plan', - price: 100, - billingCycleDays: 30, - prorate: true, - } as CreateSubscriptionPlanDto; - - const mockPlanState = { id: 'state_1' } as DataLookup; - const mockPlan = { id: 'plan_1' } as SubscriptionPlan; - - dataLookupRepository.findOneBy.mockResolvedValue(mockPlanState); - subscriptionPlanRepository.create.mockReturnValue(mockPlan); - service.saveEntityWithDefaultState = jest.fn().mockResolvedValue(mockPlan); - - const result = await service.createSubscriptionPlan(createSubscriptionPlanDto); - - expect(result).toEqual(mockPlan); - expect(subscriptionPlanRepository.create).toHaveBeenCalledWith({ - ...createSubscriptionPlanDto, - status: mockPlanState, - }); - expect(service.saveEntityWithDefaultState).toHaveBeenCalledWith(mockPlan, expect.any(String)); - }); - - it('should throw NotFoundException if default state is not found', async () => { - const createSubscriptionPlanDto: CreateSubscriptionPlanDto = { - name: 'Plan A', - description: 'Basic Plan', - price: 100, - billingCycleDays: 30, - prorate: true, - } as CreateSubscriptionPlanDto; - - dataLookupRepository.findOneBy.mockResolvedValue(null); - - await expect(service.createSubscriptionPlan(createSubscriptionPlanDto)).rejects.toThrow(NotFoundException); - }); - }); - - describe('getSubscriptionPlans', () => { - it('should return subscription plans', async () => { - const mockPlans = [{ id: 'plan_1' }] as SubscriptionPlan[]; - - subscriptionPlanRepository.find.mockResolvedValue(mockPlans); - - const result = await service.getSubscriptionPlans(); - - expect(result).toEqual(mockPlans); - expect(subscriptionPlanRepository.find).toHaveBeenCalledWith({ - relations: ['status', 'objectState'], - }); - }); - }); - - describe('getSubscriptionPlanById', () => { - it('should return a subscription plan by id', async () => { - const planId = 'plan_1'; - const mockPlan = { id: 'plan_1' } as SubscriptionPlan; - - subscriptionPlanRepository.findOne.mockResolvedValue(mockPlan); - - const result = await service.getSubscriptionPlanById(planId); - - expect(result).toEqual(mockPlan); - expect(subscriptionPlanRepository.findOne).toHaveBeenCalledWith({ - where: { id: planId }, - relations: ['status', 'objectState'], - }); - }); - - it('should throw NotFoundException if subscription plan is not found', async () => { - const planId = 'plan_1'; - - subscriptionPlanRepository.findOne.mockResolvedValue(null); - - await expect(service.getSubscriptionPlanById(planId)).rejects.toThrow(NotFoundException); - }); - }); - - describe('updateSubscriptionPlan', () => { - it('should update a subscription plan', async () => { - const planId = 'plan_1'; - const updateSubscriptionPlanDto: UpdateSubscriptionPlanDto = { - name: 'Updated Plan', - description: 'Updated Description', - price: 200, - billingCycleDays: 45, - statusId: 'status_2', - prorate: false, - }; - - const mockPlan = { id: 'plan_1' } as SubscriptionPlan; - const mockStatus = { id: 'status_2' } as DataLookup; - - service.getSubscriptionPlanById = jest.fn().mockResolvedValue(mockPlan); - dataLookupRepository.findOneBy.mockResolvedValue(mockStatus); - subscriptionPlanRepository.save.mockResolvedValue(mockPlan); - - const result = await service.updateSubscriptionPlan(planId, updateSubscriptionPlanDto); - - expect(result).toEqual(mockPlan); - expect(service.getSubscriptionPlanById).toHaveBeenCalledWith(planId); - expect(dataLookupRepository.findOneBy).toHaveBeenCalledWith({ id: updateSubscriptionPlanDto.statusId }); - expect(subscriptionPlanRepository.save).toHaveBeenCalledWith(mockPlan); - }); - - it('should throw NotFoundException if status is not found', async () => { - const planId = 'plan_1'; - const updateSubscriptionPlanDto: UpdateSubscriptionPlanDto = { - name: 'Updated Plan', - description: 'Updated Description', - price: 200, - billingCycleDays: 45, - statusId: 'status_2', - prorate: false, - }; - - const mockPlan = { id: 'plan_1' } as SubscriptionPlan; - - service.getSubscriptionPlanById = jest.fn().mockResolvedValue(mockPlan); - dataLookupRepository.findOneBy.mockResolvedValue(null); - - await expect(service.updateSubscriptionPlan(planId, updateSubscriptionPlanDto)).rejects.toThrow(NotFoundException); - }); - }); - - describe('deleteSubscriptionPlan', () => { - it('should delete a subscription plan', async () => { - service.destroy = jest.fn().mockResolvedValue(undefined); - - await service.deleteSubscriptionPlan('plan_1'); + entityManager.findOne.mockResolvedValueOnce(mockSubscription); // Subscription found + entityManager.findOne.mockResolvedValueOnce(null); // SubscriptionStatus not found - expect(service.destroy).toHaveBeenCalledWith('plan_1'); + await expect(service.updateSubscriptionStatus(subscriptionId, updateSubscriptionStatusDto, entityManager)).rejects.toThrow(NotFoundException); }); }); });