From 644c8a07eab2c1020532c43293f18ee1fdbdc14b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 30 Apr 2026 14:17:12 +0200 Subject: [PATCH] chore(system-settings): migrate to NestJS Signed-off-by: William Phetsinorath Co-authored-by: Kevin Powell Noumbissie <10553243+KepoParis@users.noreply.github.com> --- .../MODULARISATION-CARTOGRAPHIE.md | 4 +- apps/server-nestjs/src/main.module.ts | 2 + .../pipe/zod-validation.pipe.spec.ts | 100 ++++++++++++++++++ .../pipe/zod-validation.pipe.ts | 17 +++ .../system-settings-testing.utils.ts | 15 +++ .../system-settings.controller.ts | 24 +++++ .../system-settings/system-settings.module.ts | 12 +++ .../system-settings.service.spec.ts | 84 +++++++++++++++ .../system-settings.service.ts | 20 ++++ 9 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts create mode 100644 apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts create mode 100644 apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts create mode 100644 apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts create mode 100644 apps/server-nestjs/src/modules/system-settings/system-settings.module.ts create mode 100644 apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/system-settings/system-settings.service.ts diff --git a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md index da49ddec3..233878684 100644 --- a/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md +++ b/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -178,7 +178,7 @@ Plus le score est eleve, plus le module est prioritaire. |------|--------|------|-------|-------|--------|--------| | 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | ✅ MIGRE | | 2 | system (health/version) | Metier | 7.8 | V1 | S3 | ✅ MIGRE | -| 3 | system/settings | Metier | 7.4 | V1 | S3 | | +| 3 | system/settings | Metier | 7.4 | V1 | S3 | ✅ MIGRE | | 4 | system/config | Metier | 7.4 | V1 | S3-S4 | | | 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | ✅ MIGRE | | 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | | @@ -246,7 +246,7 @@ les premiers modules via Nginx. --- -### 2. system/settings +### 2. system/settings — ✅ MIGRE (2026-04-28) | Attribut | Valeur | |----------|--------| diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 300cef696..2ed5eac10 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' import { ServiceChainModule } from './modules/service-chain/service-chain.module' +import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' @Module({ @@ -12,6 +13,7 @@ import { VersionModule } from './modules/version/version.module' HealthzModule, KeycloakModule, ScheduleModule.forRoot(), + SystemSettingsModule, ServiceChainModule, VersionModule, ], diff --git a/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts b/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts new file mode 100644 index 000000000..6fd1db8ee --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common' +import { beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { ZodValidationPipe } from './zod-validation.pipe' + +describe('zodValidationPipe', () => { + const schema = z.object({ + name: z.string(), + age: z.number().int().positive(), + }) + + let pipe: ZodValidationPipe + + beforeEach(() => { + pipe = new ZodValidationPipe(schema) + }) + + describe('transform', () => { + it('should return validated data when input is valid', () => { + const validInput = { + name: 'Alice', + age: 30, + } + + const result = pipe.transform(validInput) + + expect(result).toEqual(validInput) + }) + + it('should strip unknown fields if schema does not allow them', () => { + const input = { + name: 'Alice', + age: 30, + extra: 'remove me', + } + + const result = pipe.transform(input) + + expect(result).toEqual({ + name: 'Alice', + age: 30, + }) + }) + + it('should throw BadRequestException when required fields are missing', () => { + const invalidInput = { + name: 'Alice', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should throw BadRequestException when field types are invalid', () => { + const invalidInput = { + name: 'Alice', + age: '30', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should include flattened Zod errors in the exception response', () => { + const invalidInput = { + name: 123, + age: -5, + } + + try { + pipe.transform(invalidInput) + throw new Error('Expected transform to throw') + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException) + + if (error instanceof BadRequestException) { + const response = error.getResponse() as any + + expect(response).toHaveProperty('fieldErrors') + expect(response.fieldErrors).toHaveProperty('name') + expect(response.fieldErrors).toHaveProperty('age') + } + } + }) + + it('should throw when input is null', () => { + expect(() => pipe.transform(null)).toThrow( + BadRequestException, + ) + }) + + it('should throw when input is undefined', () => { + expect(() => pipe.transform(undefined)).toThrow( + BadRequestException, + ) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts b/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts new file mode 100644 index 000000000..066fa255d --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts @@ -0,0 +1,17 @@ +import type { PipeTransform } from '@nestjs/common' +import type { ZodSchema } from 'zod' +import { BadRequestException } from '@nestjs/common' + +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown) { + const result = this.schema.safeParse(value) + + if (!result.success) { + throw new BadRequestException(result.error.flatten()) + } + + return result.data + } +} diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts b/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts new file mode 100644 index 000000000..bdc5e91f2 --- /dev/null +++ b/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts @@ -0,0 +1,15 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeSystemSetting() { + return { + key: faker.string.alphanumeric(), + value: faker.string.alphanumeric(), + } satisfies SystemSetting +} + +export function makeSystemSettings() { + return faker.helpers.multiple(() => makeSystemSetting(), { + count: faker.number.int({ min: 1, max: 10 }), + }) +} diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts b/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts new file mode 100644 index 000000000..d3836db25 --- /dev/null +++ b/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts @@ -0,0 +1,24 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { SystemSettingSchema } from '@cpn-console/shared' +import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe.js' +import { SystemSettingsService } from './system-settings.service' + +@Controller('api/v1/system/settings') +export class SystemSettingsController { + constructor(@Inject(SystemSettingsService) private readonly service: SystemSettingsService) {} + + @Get() + async list( + @Query() query: string, + ) { + return this.service.list(query) + } + + @Put(':key') + async upsert( + @Body(new ZodValidationPipe(SystemSettingSchema)) data: SystemSetting, + ) { + return this.service.upsert(data) + } +} diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts b/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 000000000..50c1ca5ec --- /dev/null +++ b/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { SystemSettingsController } from './system-settings.controller' +import { SystemSettingsService } from './system-settings.service' + +@Module({ + imports: [InfrastructureModule], + controllers: [SystemSettingsController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts b/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts new file mode 100644 index 000000000..e674e2358 --- /dev/null +++ b/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts @@ -0,0 +1,84 @@ +import type { TestingModule } from '@nestjs/testing' +import type { PrismaClient } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service.js' +import { makeSystemSetting, makeSystemSettings } from './system-settings-testing.utils' +import { SystemSettingsService } from './system-settings.service' + +describe('systemSettingsService', () => { + let module: TestingModule + let service: SystemSettingsService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + SystemSettingsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(SystemSettingsService) + }) + + describe('list', () => { + it('should return all settings', async () => { + const systemSettings = makeSystemSettings() + + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual(systemSettings) + }) + + it('should return empty array if no settings are found', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual([]) + }) + + it('should return setting by key', async () => { + const systemSettings = makeSystemSettings() + const systemSetting = faker.helpers.arrayElement(systemSettings) + prisma.systemSetting.findMany.mockResolvedValue([systemSetting]) + + const result = await service.list(systemSetting.key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: systemSetting.key } }) + expect(result).toEqual([systemSetting]) + }) + + it('should return empty array if key is not found', async () => { + const key = faker.string.alphanumeric(10) + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list(key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key } }) + expect(result).toEqual([]) + }) + }) + + describe('upsert', () => { + it('should update setting if it exists', async () => { + const systemSetting = makeSystemSetting() + prisma.systemSetting.upsert.mockResolvedValue(systemSetting) + + const result = await service.upsert(systemSetting) + + expect(result.key).toBe(systemSetting.key) + expect(result.value).toBe(systemSetting.value) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts b/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 000000000..06c5e7260 --- /dev/null +++ b/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,20 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class SystemSettingsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(key?: string): Promise { + return this.prisma.systemSetting.findMany({ where: { key } }) + } + + async upsert(data: SystemSetting): Promise { + return this.prisma.systemSetting.upsert({ + create: data, + update: data, + where: { key: data.key }, + }) + } +}