Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down Expand Up @@ -246,7 +246,7 @@ les premiers modules via Nginx.

---

### 2. system/settings
### 2. system/settings — ✅ MIGRE (2026-04-28)

| Attribut | Valeur |
|----------|--------|
Expand Down
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -12,6 +13,7 @@ import { VersionModule } from './modules/version/version.module'
HealthzModule,
KeycloakModule,
ScheduleModule.forRoot(),
SystemSettingsModule,
ServiceChainModule,
VersionModule,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
})
})
})
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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 }),
})
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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<PrismaClient>

beforeEach(async () => {
prisma = mockDeep<PrismaClient>()

module = await Test.createTestingModule({
providers: [
SystemSettingsService,
{ provide: PrismaService, useValue: prisma },
],
}).compile()

service = module.get<SystemSettingsService>(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)
})
})
})
Original file line number Diff line number Diff line change
@@ -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<SystemSetting[]> {
return this.prisma.systemSetting.findMany({ where: { key } })
}

async upsert(data: SystemSetting): Promise<SystemSetting> {
return this.prisma.systemSetting.upsert({
create: data,
update: data,
where: { key: data.key },
})
}
}
Loading