From 3a614bab72e61abb285067e219b2c05f962a1eb6 Mon Sep 17 00:00:00 2001 From: MatthieuTD <39524319+MatthieuTD@users.noreply.github.com> Date: Mon, 22 Sep 2025 10:20:40 +0200 Subject: [PATCH] Validate component and piece requirements --- src/composants/composants.service.spec.ts | 71 ++++++++++- src/composants/composants.service.ts | 102 +++++++++++++++- src/pieces/pieces.service.spec.ts | 72 ++++++++++- src/pieces/pieces.service.ts | 101 +++++++++++++++- src/shared/dto/composant.dto.ts | 9 +- src/shared/dto/piece.dto.ts | 9 +- test/app.e2e-spec.ts | 139 +++++++++++++++++++++- 7 files changed, 486 insertions(+), 17 deletions(-) diff --git a/src/composants/composants.service.spec.ts b/src/composants/composants.service.spec.ts index 1ee01cc..05ac739 100644 --- a/src/composants/composants.service.spec.ts +++ b/src/composants/composants.service.spec.ts @@ -1,19 +1,84 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { ComposantsService } from './composants.service'; import { PrismaService } from '../prisma/prisma.service'; +import { CreateComposantDto } from '../shared/dto/composant.dto'; describe('ComposantsService', () => { let service: ComposantsService; + let prisma: any; beforeEach(async () => { + prisma = { + composant: { + create: jest.fn(), + findUnique: jest.fn(), + }, + machine: { + findUnique: jest.fn(), + }, + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [ComposantsService, PrismaService], + providers: [ComposantsService, { provide: PrismaService, useValue: prisma }], }).compile(); service = module.get(ComposantsService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create a component when requirement matches the machine skeleton', async () => { + const dto: CreateComposantDto = { + name: 'Comp A', + machineId: 'machine-1', + typeComposantId: 'type-comp-1', + typeMachineComponentRequirementId: 'req-1', + }; + + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + componentRequirements: [ + { id: 'req-1', typeComposantId: 'type-comp-1' }, + ], + }, + }); + + const created = { id: 'component-1' }; + prisma.composant.create.mockResolvedValue(created); + + await expect(service.create(dto)).resolves.toEqual(created); + + expect(prisma.composant.create).toHaveBeenCalled(); + expect( + prisma.composant.create.mock.calls[0][0].data.typeComposantId, + ).toBe('type-comp-1'); + }); + + it('should refuse creation when requirement does not belong to machine skeleton', async () => { + const dto: CreateComposantDto = { + name: 'Comp A', + machineId: 'machine-1', + typeComposantId: 'type-comp-1', + typeMachineComponentRequirementId: 'req-2', + }; + + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + componentRequirements: [ + { id: 'req-1', typeComposantId: 'type-comp-1' }, + ], + }, + }); + + await expect(service.create(dto)).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(prisma.composant.create).not.toHaveBeenCalled(); }); }); diff --git a/src/composants/composants.service.ts b/src/composants/composants.service.ts index 26e750c..266c69f 100644 --- a/src/composants/composants.service.ts +++ b/src/composants/composants.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateComposantDto, UpdateComposantDto } from '../shared/dto/composant.dto'; @@ -7,8 +7,75 @@ export class ComposantsService { constructor(private prisma: PrismaService) {} async create(createComposantDto: CreateComposantDto) { + const requirementId = createComposantDto.typeMachineComponentRequirementId; + + let machineId = createComposantDto.machineId; + + if (createComposantDto.parentComposantId) { + const parentMachineId = await this.resolveMachineIdFromComposant( + createComposantDto.parentComposantId, + ); + + if (machineId && machineId !== parentMachineId) { + throw new BadRequestException( + 'Le composant parent ne correspond pas à la machine ciblée.', + ); + } + + machineId = parentMachineId; + } + + if (!machineId) { + throw new BadRequestException( + 'Un machineId ou un parentComposantId valide est requis pour créer un composant.', + ); + } + + const machine = await this.prisma.machine.findUnique({ + where: { id: machineId }, + include: { + typeMachine: { + include: { + componentRequirements: true, + }, + }, + }, + }); + + if (!machine || !machine.typeMachine) { + throw new BadRequestException( + 'La machine ciblée doit être associée à un type de machine pour valider les requirements.', + ); + } + + const requirement = machine.typeMachine.componentRequirements.find( + (componentRequirement) => componentRequirement.id === requirementId, + ); + + if (!requirement) { + throw new BadRequestException( + 'Le requirement de composant fourni ne correspond pas au squelette de la machine.', + ); + } + + if ( + createComposantDto.typeComposantId && + createComposantDto.typeComposantId !== requirement.typeComposantId + ) { + throw new BadRequestException( + 'Le type de composant fourni ne correspond pas au requirement pour cette machine.', + ); + } + + const data = { + ...createComposantDto, + machineId, + typeComposantId: + createComposantDto.typeComposantId ?? requirement.typeComposantId, + }; + return this.prisma.composant.create({ - data: createComposantDto, + data, include: { machine: true, parentComposant: true, @@ -437,6 +504,37 @@ export class ComposantsService { }); } + private async resolveMachineIdFromComposant( + composantId: string, + ): Promise { + const composant = await this.prisma.composant.findUnique({ + where: { id: composantId }, + select: { + id: true, + machineId: true, + parentComposantId: true, + }, + }); + + if (!composant) { + throw new BadRequestException( + 'Le composant parent spécifié est introuvable.', + ); + } + + if (composant.machineId) { + return composant.machineId; + } + + if (composant.parentComposantId) { + return this.resolveMachineIdFromComposant(composant.parentComposantId); + } + + throw new BadRequestException( + 'Impossible de déterminer la machine associée au composant parent.', + ); + } + async remove(id: string) { return this.prisma.composant.delete({ where: { id }, diff --git a/src/pieces/pieces.service.spec.ts b/src/pieces/pieces.service.spec.ts index 8fe3047..6837166 100644 --- a/src/pieces/pieces.service.spec.ts +++ b/src/pieces/pieces.service.spec.ts @@ -1,19 +1,85 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { PiecesService } from './pieces.service'; import { PrismaService } from '../prisma/prisma.service'; +import { CreatePieceDto } from '../shared/dto/piece.dto'; describe('PiecesService', () => { let service: PiecesService; + let prisma: any; beforeEach(async () => { + prisma = { + piece: { + create: jest.fn(), + }, + machine: { + findUnique: jest.fn(), + }, + composant: { + findUnique: jest.fn(), + }, + }; + const module: TestingModule = await Test.createTestingModule({ - providers: [PiecesService, PrismaService], + providers: [PiecesService, { provide: PrismaService, useValue: prisma }], }).compile(); service = module.get(PiecesService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create a piece when requirement matches the machine skeleton', async () => { + const dto: CreatePieceDto = { + name: 'Piece A', + machineId: 'machine-1', + typePieceId: 'type-piece-1', + typeMachinePieceRequirementId: 'req-1', + }; + + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + pieceRequirements: [ + { id: 'req-1', typePieceId: 'type-piece-1' }, + ], + }, + }); + + const created = { id: 'piece-1' }; + prisma.piece.create.mockResolvedValue(created); + + await expect(service.create(dto)).resolves.toEqual(created); + expect(prisma.piece.create).toHaveBeenCalled(); + expect(prisma.piece.create.mock.calls[0][0].data.machineId).toBe( + 'machine-1', + ); + }); + + it('should refuse creation when requirement does not belong to machine skeleton', async () => { + const dto: CreatePieceDto = { + name: 'Piece A', + machineId: 'machine-1', + typePieceId: 'type-piece-1', + typeMachinePieceRequirementId: 'req-2', + }; + + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + pieceRequirements: [ + { id: 'req-1', typePieceId: 'type-piece-1' }, + ], + }, + }); + + await expect(service.create(dto)).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(prisma.piece.create).not.toHaveBeenCalled(); }); }); diff --git a/src/pieces/pieces.service.ts b/src/pieces/pieces.service.ts index e507d98..5980c22 100644 --- a/src/pieces/pieces.service.ts +++ b/src/pieces/pieces.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto'; @@ -7,8 +7,74 @@ export class PiecesService { constructor(private prisma: PrismaService) {} async create(createPieceDto: CreatePieceDto) { + const requirementId = createPieceDto.typeMachinePieceRequirementId; + + let machineId = createPieceDto.machineId; + + if (createPieceDto.composantId) { + const composantMachineId = await this.resolveMachineIdFromComposant( + createPieceDto.composantId, + ); + + if (machineId && machineId !== composantMachineId) { + throw new BadRequestException( + 'Le composant ciblé appartient à une autre machine que celle fournie.', + ); + } + + machineId = composantMachineId; + } + + if (!machineId) { + throw new BadRequestException( + 'Un machineId ou un composantId valide est requis pour créer une pièce.', + ); + } + + const machine = await this.prisma.machine.findUnique({ + where: { id: machineId }, + include: { + typeMachine: { + include: { + pieceRequirements: true, + }, + }, + }, + }); + + if (!machine || !machine.typeMachine) { + throw new BadRequestException( + 'La machine ciblée doit être associée à un type de machine pour valider les requirements.', + ); + } + + const requirement = machine.typeMachine.pieceRequirements.find( + (pieceRequirement) => pieceRequirement.id === requirementId, + ); + + if (!requirement) { + throw new BadRequestException( + 'Le requirement de pièce fourni ne correspond pas au squelette de la machine.', + ); + } + + if ( + createPieceDto.typePieceId && + createPieceDto.typePieceId !== requirement.typePieceId + ) { + throw new BadRequestException( + 'Le type de pièce fourni ne correspond pas au requirement pour cette machine.', + ); + } + + const data = { + ...createPieceDto, + machineId, + typePieceId: createPieceDto.typePieceId ?? requirement.typePieceId, + }; + return this.prisma.piece.create({ - data: createPieceDto, + data, include: { machine: true, composant: true, @@ -101,6 +167,37 @@ export class PiecesService { }); } + private async resolveMachineIdFromComposant( + composantId: string, + ): Promise { + const composant = await this.prisma.composant.findUnique({ + where: { id: composantId }, + select: { + id: true, + machineId: true, + parentComposantId: true, + }, + }); + + if (!composant) { + throw new BadRequestException( + 'Le composant spécifié est introuvable.', + ); + } + + if (composant.machineId) { + return composant.machineId; + } + + if (composant.parentComposantId) { + return this.resolveMachineIdFromComposant(composant.parentComposantId); + } + + throw new BadRequestException( + 'Impossible de déterminer la machine associée à ce composant.', + ); + } + async findByComposant(composantId: string) { return this.prisma.piece.findMany({ where: { composantId }, diff --git a/src/shared/dto/composant.dto.ts b/src/shared/dto/composant.dto.ts index 1c9d827..ba02f9d 100644 --- a/src/shared/dto/composant.dto.ts +++ b/src/shared/dto/composant.dto.ts @@ -1,15 +1,15 @@ -import { IsString, IsOptional, IsNumber } from 'class-validator'; +import { IsString, IsOptional, IsNumber, ValidateIf } from 'class-validator'; import { Transform } from 'class-transformer'; export class CreateComposantDto { @IsString() name: string; - @IsOptional() + @ValidateIf((dto) => !dto.parentComposantId) @IsString() machineId?: string; - @IsOptional() + @ValidateIf((dto) => !dto.machineId) @IsString() parentComposantId?: string; @@ -34,6 +34,9 @@ export class CreateComposantDto { @IsString() typeComposantId?: string; + @IsString() + typeMachineComponentRequirementId: string; + @IsOptional() @IsString() composantModelId?: string; diff --git a/src/shared/dto/piece.dto.ts b/src/shared/dto/piece.dto.ts index 7b65e5f..b7d7d2f 100644 --- a/src/shared/dto/piece.dto.ts +++ b/src/shared/dto/piece.dto.ts @@ -1,15 +1,15 @@ -import { IsString, IsOptional, IsNumber } from 'class-validator'; +import { IsString, IsOptional, IsNumber, ValidateIf } from 'class-validator'; import { Transform } from 'class-transformer'; export class CreatePieceDto { @IsString() name: string; - @IsOptional() + @ValidateIf((dto) => !dto.composantId) @IsString() machineId?: string; - @IsOptional() + @ValidateIf((dto) => !dto.machineId) @IsString() composantId?: string; @@ -34,6 +34,9 @@ export class CreatePieceDto { @IsString() typePieceId?: string; + @IsString() + typeMachinePieceRequirementId: string; + @IsOptional() @IsString() pieceModelId?: string; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 4df6580..846d2df 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -3,23 +3,160 @@ import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('AppController (e2e)', () => { let app: INestApplication; + function createMockPrismaService() { + return { + composant: { + create: jest.fn(), + findUnique: jest.fn(), + }, + piece: { + create: jest.fn(), + }, + machine: { + findUnique: jest.fn(), + }, + profile: { + count: jest.fn().mockResolvedValue(0), + create: jest.fn().mockResolvedValue({ id: 'profile-1' }), + }, + onModuleInit: jest.fn(), + onModuleDestroy: jest.fn(), + }; + } + + let prisma: ReturnType; + beforeEach(async () => { + prisma = createMockPrismaService(); + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(PrismaService) + .useValue(prisma) + .compile(); app = moduleFixture.createNestApplication(); await app.init(); }); + afterEach(async () => { + await app.close(); + }); + it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) .expect('Hello World!'); }); + + describe('POST /composants', () => { + it('accepts creation when requirement matches the machine skeleton', async () => { + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + componentRequirements: [ + { id: 'req-1', typeComposantId: 'type-comp-1' }, + ], + }, + }); + + const created = { id: 'component-1' }; + prisma.composant.create.mockResolvedValue(created); + + const response = await request(app.getHttpServer()) + .post('/composants') + .send({ + name: 'Comp A', + machineId: 'machine-1', + typeComposantId: 'type-comp-1', + typeMachineComponentRequirementId: 'req-1', + }) + .expect(201); + + expect(response.body).toEqual(created); + expect(prisma.composant.create).toHaveBeenCalled(); + }); + + it('refuses creation when requirement is not part of the machine skeleton', async () => { + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + componentRequirements: [ + { id: 'req-1', typeComposantId: 'type-comp-1' }, + ], + }, + }); + + await request(app.getHttpServer()) + .post('/composants') + .send({ + name: 'Comp A', + machineId: 'machine-1', + typeComposantId: 'type-comp-1', + typeMachineComponentRequirementId: 'req-2', + }) + .expect(400); + + expect(prisma.composant.create).not.toHaveBeenCalled(); + }); + }); + + describe('POST /pieces', () => { + it('accepts creation when requirement matches the machine skeleton', async () => { + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + pieceRequirements: [ + { id: 'req-1', typePieceId: 'type-piece-1' }, + ], + }, + }); + + const created = { id: 'piece-1' }; + prisma.piece.create.mockResolvedValue(created); + + const response = await request(app.getHttpServer()) + .post('/pieces') + .send({ + name: 'Piece A', + machineId: 'machine-1', + typePieceId: 'type-piece-1', + typeMachinePieceRequirementId: 'req-1', + }) + .expect(201); + + expect(response.body).toEqual(created); + expect(prisma.piece.create).toHaveBeenCalled(); + }); + + it('refuses creation when requirement is not part of the machine skeleton', async () => { + prisma.machine.findUnique.mockResolvedValue({ + id: 'machine-1', + typeMachine: { + pieceRequirements: [ + { id: 'req-1', typePieceId: 'type-piece-1' }, + ], + }, + }); + + await request(app.getHttpServer()) + .post('/pieces') + .send({ + name: 'Piece A', + machineId: 'machine-1', + typePieceId: 'type-piece-1', + typeMachinePieceRequirementId: 'req-2', + }) + .expect(400); + + expect(prisma.piece.create).not.toHaveBeenCalled(); + }); + }); });