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 c20dbd2..1f6ef26 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -9,10 +9,40 @@ describe('AppController (e2e)', () => { let app: INestApplication; let prisma: PrismaService; - beforeAll(async () => { + + 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(); @@ -30,6 +60,10 @@ describe('AppController (e2e)', () => { ); }); + afterEach(async () => { + await app.close(); + }); + it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') @@ -37,255 +71,108 @@ describe('AppController (e2e)', () => { .expect('Hello World!'); }); - describe('Machines skeleton reconfiguration', () => { - it('reconfigures machine skeleton according to updated requirements', async () => { - const site = await prisma.site.create({ - data: { - name: 'Site principal', - contactName: 'Jane Doe', - contactPhone: '0102030405', - contactAddress: '1 rue Principale', - contactPostalCode: '75000', - contactCity: 'Paris', + 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 typeComposant = await prisma.typeComposant.create({ - data: { - name: 'Module', - description: 'Module principal', - }, - }); + const created = { id: 'component-1' }; + prisma.composant.create.mockResolvedValue(created); - await prisma.customField.create({ - data: { - name: 'Serial', - type: 'text', - required: false, - options: [], - typeComposantId: typeComposant.id, - }, - }); - - const typePiece = await prisma.typePiece.create({ - data: { - name: 'Pièce de rechange', - description: 'Pièce critique', - }, - }); - - await prisma.customField.create({ - data: { - name: 'Batch', - type: 'text', - required: false, - options: [], - typePieceId: typePiece.id, - }, - }); - - const componentModelV1 = await prisma.composantModel.create({ - data: { - name: 'Module V1', - typeComposantId: typeComposant.id, - structure: { - name: 'Module V1', - customFields: [{ name: 'Serial', defaultValue: 'SERIAL-V1' }], - }, - }, - }); - - const componentModelV2 = await prisma.composantModel.create({ - data: { - name: 'Module V2', - typeComposantId: typeComposant.id, - structure: { - name: 'Module V2', - customFields: [{ name: 'Serial', defaultValue: 'SERIAL-V2' }], - }, - }, - }); - - const pieceModelV1 = await prisma.pieceModel.create({ - data: { - name: 'Pièce V1', - typePieceId: typePiece.id, - structure: { - name: 'Pièce V1', - customFields: [{ name: 'Batch', defaultValue: 'BATCH-V1' }], - }, - }, - }); - - const pieceModelV2 = await prisma.pieceModel.create({ - data: { - name: 'Pièce V2', - typePieceId: typePiece.id, - structure: { - name: 'Pièce V2', - customFields: [{ name: 'Batch', defaultValue: 'BATCH-V2' }], - }, - }, - }); - - const typeMachine = await prisma.typeMachine.create({ - data: { - name: 'Type Machine A', - componentRequirements: { - create: [ - { - label: 'Module initial', - minCount: 1, - maxCount: 1, - required: true, - allowNewModels: false, - typeComposantId: typeComposant.id, - }, - ], - }, - pieceRequirements: { - create: [ - { - label: 'Pièce initiale', - minCount: 1, - maxCount: 1, - required: true, - allowNewModels: false, - typePieceId: typePiece.id, - }, - ], - }, - }, - include: { - componentRequirements: true, - pieceRequirements: true, - }, - }); - - const initialComponentRequirement = typeMachine.componentRequirements[0]; - const initialPieceRequirement = typeMachine.pieceRequirements[0]; - - const createResponse = await request(app.getHttpServer()) - .post('/machines') + const response = await request(app.getHttpServer()) + .post('/composants') .send({ - name: 'Machine Alpha', - siteId: site.id, - typeMachineId: typeMachine.id, - componentSelections: [ - { - requirementId: initialComponentRequirement.id, - componentModelId: componentModelV1.id, - }, - ], - pieceSelections: [ - { - requirementId: initialPieceRequirement.id, - pieceModelId: pieceModelV1.id, - }, - ], + name: 'Comp A', + machineId: 'machine-1', + typeComposantId: 'type-comp-1', + typeMachineComponentRequirementId: 'req-1', }) .expect(201); - const machineId = createResponse.body.id as string; + expect(response.body).toEqual(created); + expect(prisma.composant.create).toHaveBeenCalled(); + }); - const initialComponents = await prisma.composant.findMany({ where: { machineId } }); - expect(initialComponents).toHaveLength(1); - const initialComponentId = initialComponents[0].id; - - const initialPieces = await prisma.piece.findMany({ where: { machineId } }); - expect(initialPieces).toHaveLength(1); - const initialPieceId = initialPieces[0].id; - - expect( - await prisma.customFieldValue.count({ where: { composantId: initialComponentId } }), - ).toBe(1); - expect(await prisma.customFieldValue.count({ where: { pieceId: initialPieceId } })).toBe(1); - - await prisma.typeMachineComponentRequirement.deleteMany({ - where: { typeMachineId: typeMachine.id }, - }); - await prisma.typeMachinePieceRequirement.deleteMany({ - where: { typeMachineId: typeMachine.id }, - }); - - const newComponentRequirement = await prisma.typeMachineComponentRequirement.create({ - data: { - label: 'Modules mis à jour', - minCount: 2, - maxCount: 2, - required: true, - allowNewModels: true, - typeMachineId: typeMachine.id, - typeComposantId: typeComposant.id, + 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' }, + ], }, }); - const newPieceRequirement = await prisma.typeMachinePieceRequirement.create({ - data: { - label: 'Pièce mise à jour', - minCount: 1, - maxCount: 1, - required: true, - allowNewModels: false, - typeMachineId: typeMachine.id, - typePieceId: typePiece.id, - }, - }); - - const reconfigureResponse = await request(app.getHttpServer()) - .patch(`/machines/${machineId}/skeleton`) + await request(app.getHttpServer()) + .post('/composants') .send({ - componentSelections: [ - { - requirementId: newComponentRequirement.id, - componentModelId: componentModelV2.id, - }, - { - requirementId: newComponentRequirement.id, - definition: { - name: 'Module personnalisé', - customFields: [{ name: 'Serial', defaultValue: 'SERIAL-CUSTOM' }], - }, - }, - ], - pieceSelections: [ - { - requirementId: newPieceRequirement.id, - pieceModelId: pieceModelV2.id, - }, - ], + name: 'Comp A', + machineId: 'machine-1', + typeComposantId: 'type-comp-1', + typeMachineComponentRequirementId: 'req-2', }) - .expect(200); + .expect(400); - expect(reconfigureResponse.body.composants).toHaveLength(2); - expect(reconfigureResponse.body.pieces).toHaveLength(1); + expect(prisma.composant.create).not.toHaveBeenCalled(); + }); + }); - const componentsAfter = await prisma.composant.findMany({ where: { machineId } }); - expect(componentsAfter).toHaveLength(2); - const componentIdsAfter = componentsAfter.map((component) => component.id); - expect(componentIdsAfter).not.toContain(initialComponentId); - componentsAfter.forEach((component) => { - expect(component.typeMachineComponentRequirementId).toBe(newComponentRequirement.id); + 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 componentValueCount = await prisma.customFieldValue.count({ - where: { composantId: { in: componentIdsAfter } }, + 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' }, + ], + }, }); - expect(componentValueCount).toBe(2); - expect( - await prisma.customFieldValue.count({ where: { composantId: initialComponentId } }), - ).toBe(0); - const piecesAfter = await prisma.piece.findMany({ where: { machineId } }); - expect(piecesAfter).toHaveLength(1); - const [updatedPiece] = piecesAfter; - expect(updatedPiece.typeMachinePieceRequirementId).toBe(newPieceRequirement.id); - expect(updatedPiece.id).not.toBe(initialPieceId); + 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(); - expect( - await prisma.customFieldValue.count({ where: { pieceId: updatedPiece.id } }), - ).toBe(1); - expect(await prisma.customFieldValue.count({ where: { pieceId: initialPieceId } })).toBe(0); }); }); });