diff --git a/back/src/modules/apis/apis.service.spec.ts b/back/src/modules/apis/apis.service.spec.ts index a2dd104..53d93df 100644 --- a/back/src/modules/apis/apis.service.spec.ts +++ b/back/src/modules/apis/apis.service.spec.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { ConflictException, NotFoundException } from '@nestjs/common'; import { ApisService } from './apis.service'; import { ApiType } from '@datacat/shared'; @@ -154,3 +155,193 @@ describe('ApisService.parseSearchTokens()', () => { }); }); }); + +// ─── Mocks partagés pour les tests unitaires de ApisService ─────────────────── + +const mockRepo = { + findOne: vi.fn(), + count: vi.fn(), + create: vi.fn(), + save: vi.fn(), + find: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}; + +const mockDocsService = { generate: vi.fn() }; + +let service: ApisService; + +beforeEach(() => { + vi.clearAllMocks(); + service = new ApisService(mockRepo as any, mockDocsService as any); +}); + +function makeEntity(overrides: Record = {}) { + return { + id: 'uuid-1', name: 'Petstore', convention: 'CONSULTER', + versionMajor: 1, versionMinor: 0, versionPatch: 0, + type: 'OPENAPI', provider: 'DINUM', description: null, + yamlContent: 'openapi: "3.0.0"', htmlPath: null, + status: 'PENDING', errorMessage: null, categoryId: null, + isCurrent: true, + contactFunctionalName: '', contactFunctionalEntity: '', contactFunctionalEmail: '', + contactTechnicalName: '', contactTechnicalEntity: '', contactTechnicalEmail: '', + createdAt: new Date(), updatedAt: new Date(), + ...overrides, + }; +} + +const baseDto = { + name: 'Petstore', convention: 'CONSULTER' as const, + versionMajor: 1, versionMinor: 0, versionPatch: 0, + type: 'OPENAPI' as const, provider: 'DINUM', + yamlContent: 'openapi: "3.0.0"', + contactFunctionalName: 'Alice', contactFunctionalEntity: 'DINUM', contactFunctionalEmail: 'alice@dinum.fr', + contactTechnicalName: 'Bob', contactTechnicalEntity: 'DINUM', contactTechnicalEmail: 'bob@dinum.fr', +}; + +// ─── ApisService.create() ───────────────────────────────────────────────────── + +describe('ApisService.create()', () => { + it('crée l\'entrée et la retourne', async () => { + const entity = makeEntity(); + mockRepo.findOne.mockResolvedValue(null); + mockRepo.count.mockResolvedValue(0); + mockRepo.create.mockReturnValue(entity); + mockRepo.save.mockResolvedValue(entity); + + const result = await service.create(baseDto as any); + + expect(result.id).toBe('uuid-1'); + expect(result.title).toBe('Consulter Petstore'); + expect(result.version).toBe('1.0.0'); + }); + + it('lève ConflictException si doublon', async () => { + mockRepo.findOne.mockResolvedValue(makeEntity()); + + await expect(service.create(baseDto as any)).rejects.toThrow(ConflictException); + }); + + it('isCurrent=true si première version', async () => { + const entity = makeEntity({ isCurrent: true }); + mockRepo.findOne.mockResolvedValue(null); + mockRepo.count.mockResolvedValue(0); + mockRepo.create.mockReturnValue(entity); + mockRepo.save.mockResolvedValue(entity); + + await service.create(baseDto as any); + + expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: true })); + }); + + it('isCurrent=false si version ultérieure', async () => { + const entity = makeEntity({ isCurrent: false }); + mockRepo.findOne.mockResolvedValue(null); + mockRepo.count.mockResolvedValue(1); + mockRepo.create.mockReturnValue(entity); + mockRepo.save.mockResolvedValue(entity); + + await service.create(baseDto as any); + + expect(mockRepo.create).toHaveBeenCalledWith(expect.objectContaining({ isCurrent: false })); + }); +}); + +// ─── ApisService.findOneOrThrow() ──────────────────────────────────────────── + +describe('ApisService.findOneOrThrow()', () => { + it('retourne l\'entrée mappée si trouvée', async () => { + mockRepo.findOne.mockResolvedValue(makeEntity()); + + const result = await service.findOneOrThrow('uuid-1'); + + expect(result.id).toBe('uuid-1'); + expect(result.title).toBe('Consulter Petstore'); + expect(result.version).toBe('1.0.0'); + }); + + it('lève NotFoundException si absente', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect(service.findOneOrThrow('uuid-unknown')).rejects.toThrow(NotFoundException); + }); +}); + +// ─── ApisService.remove() ──────────────────────────────────────────────────── + +describe('ApisService.remove()', () => { + it('supprime l\'entrée si elle existe', async () => { + mockRepo.findOne.mockResolvedValue(makeEntity()); + mockRepo.delete.mockResolvedValue({ affected: 1 }); + + await service.remove('uuid-1'); + + expect(mockRepo.delete).toHaveBeenCalledWith('uuid-1'); + }); + + it('lève NotFoundException si absente', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect(service.remove('uuid-unknown')).rejects.toThrow(NotFoundException); + }); +}); + +// ─── ApisService.findVersionsOf() ──────────────────────────────────────────── + +describe('ApisService.findVersionsOf()', () => { + it('retourne toutes les versions (même name+convention)', async () => { + const entity1 = makeEntity({ id: 'uuid-1', versionMajor: 2 }); + const entity2 = makeEntity({ id: 'uuid-2', versionMajor: 1 }); + mockRepo.findOne.mockResolvedValue(entity1); + mockRepo.find.mockResolvedValue([entity1, entity2]); + + const result = await service.findVersionsOf('uuid-1'); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('uuid-1'); + expect(result[1].id).toBe('uuid-2'); + }); + + it('lève NotFoundException si id inconnu', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect(service.findVersionsOf('uuid-unknown')).rejects.toThrow(NotFoundException); + }); +}); + +// ─── ApisService.setCurrent() ──────────────────────────────────────────────── + +describe('ApisService.setCurrent()', () => { + it('met isCurrent=false sur les siblings puis true sur la cible', async () => { + mockRepo.findOne.mockResolvedValue(makeEntity()); + mockRepo.update.mockResolvedValue({ affected: 1 }); + + await service.setCurrent('uuid-1'); + + expect(mockRepo.update).toHaveBeenCalledWith( + { name: 'Petstore', convention: 'CONSULTER' }, + { isCurrent: false }, + ); + expect(mockRepo.update).toHaveBeenCalledWith( + { id: 'uuid-1' }, + { isCurrent: true }, + ); + }); +}); + +// ─── ApisService.regenerate() ──────────────────────────────────────────────── + +describe('ApisService.regenerate()', () => { + it('remet le statut à PENDING et appelle docsService.generate()', async () => { + mockRepo.findOne.mockResolvedValue(makeEntity()); + mockRepo.update.mockResolvedValue({ affected: 1 }); + mockDocsService.generate.mockResolvedValue(undefined); + + await service.regenerate('uuid-1'); + + expect(mockRepo.update).toHaveBeenCalledWith('uuid-1', { status: 'PENDING', errorMessage: null }); + expect(mockDocsService.generate).toHaveBeenCalledWith('uuid-1'); + }); +}); diff --git a/back/src/modules/apis/apis.service.ts b/back/src/modules/apis/apis.service.ts index 6d2c038..a988027 100644 --- a/back/src/modules/apis/apis.service.ts +++ b/back/src/modules/apis/apis.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, Inject, forwardRef } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ApiEntryEntity } from './api-entry.entity'; @@ -25,6 +25,22 @@ export class ApisService { ) {} async create(dto: CreateApiEntryDto): Promise { + // Vérifier qu'il n'existe pas déjà une entrée avec le même tuple (name, convention, version) + const duplicate = await this.repo.findOne({ + where: { + name: dto.name, + convention: dto.convention, + versionMajor: dto.versionMajor, + versionMinor: dto.versionMinor, + versionPatch: dto.versionPatch, + }, + }); + if (duplicate) { + throw new ConflictException( + `La version ${dto.versionMajor}.${dto.versionMinor}.${dto.versionPatch} existe déjà pour l'API "${dto.name}".`, + ); + } + // Première version si aucune entrée avec le même name + convention const existingCount = await this.repo.count({ where: { name: dto.name, convention: dto.convention }, diff --git a/front-public/src/app/pages/upload/upload.component.ts b/front-public/src/app/pages/upload/upload.component.ts index 8bf8eda..669126d 100644 --- a/front-public/src/app/pages/upload/upload.component.ts +++ b/front-public/src/app/pages/upload/upload.component.ts @@ -12,7 +12,7 @@ import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { Router, RouterLink, ActivatedRoute } from '@angular/router'; import { ApiService } from '../../core/api.service'; import { CategoryService } from '../../core/category.service'; -import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared'; +import { ApiEntry, ApiEntryListItem, Category, API_CONVENTION_LABELS, ApiConvention } from '@datacat/shared'; @Component({ selector: 'app-upload', @@ -137,6 +137,16 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac @if (step() === 2) { + + @if (fromApi(); as origin) { +
+

+ Nouvelle version de : {{ origin.title }} (v{{ origin.version }}) + — le nom et la convention sont verrouillés. +

+
+ } +
@@ -240,7 +250,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac

Version *

-
+
@@ -285,8 +295,18 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
+ @if (versionAlreadyExists()) { +
+

Version déjà existante

+

+ La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }} + existe déjà pour cette API. Choisissez un numéro de version différent. +

+
+ } +
- + @@ -411,7 +431,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
Type
-
{{ metaForm.value.type }}
+
{{ displayType() }}
Titre
@@ -499,6 +519,10 @@ export class UploadComponent implements OnInit { validationDone = signal(false); validationErrors = signal>([]); categories = signal([]); + /* API d'origine lors d'une création de nouvelle version (from=) */ + fromApi = signal(null); + /* Versions existantes de l'API d'origine (pour détecter les doublons) */ + existingVersions = signal([]); readonly stepTitles = [ 'Choisir le fichier', @@ -540,16 +564,35 @@ export class UploadComponent implements OnInit { previewTitle = computed(() => { const values = this.formValues(); - const convention = (values.convention ?? 'CONSULTER') as ApiConvention; - const name = values.name ?? ''; + const origin = this.fromApi(); + /* Quand convention/name sont désactivés, ils sont absents de valueChanges → fallback sur fromApi */ + const convention = (values.convention ?? origin?.convention ?? 'CONSULTER') as ApiConvention; + const name = values.name ?? origin?.name ?? ''; return `${API_CONVENTION_LABELS[convention]} ${name}`.trim(); }); + selectedCategoryName = computed(() => { const id = this.formValues().categoryId; if (!id) return ''; return this.categories().find((c) => c.id === id)?.name ?? ''; }); + /* Vérifie si la version saisie existe déjà parmi les versions de l'API d'origine */ + versionAlreadyExists = computed(() => { + const versions = this.existingVersions(); + if (versions.length === 0) return false; + const values = this.formValues(); + const candidate = `${values.versionMajor ?? 0}.${values.versionMinor ?? 0}.${values.versionPatch ?? 0}`; + return versions.some((v) => v.version === candidate); + }); + + /* Type effectif : getRawValue() inclut les champs désactivés */ + displayType = computed(() => { + const origin = this.fromApi(); + if (origin) return origin.type; + return (this.formValues().type ?? null) as 'ASYNCAPI' | 'OPENAPI' | null; + }); + /* Méthodes de validation (pas de computed — la re-exécution est déclenchée par les événements DOM blur/input) */ conventionInvalid(): boolean { return !!(this.metaForm.get('convention')?.invalid && this.metaForm.get('convention')?.touched); @@ -599,10 +642,15 @@ export class UploadComponent implements OnInit { next: (api) => this.prefillFromApi(api), error: () => {}, }); + this.apiService.getVersions(fromId).subscribe({ + next: (versions) => this.existingVersions.set(versions), + error: () => {}, + }); } } private prefillFromApi(api: ApiEntry): void { + this.fromApi.set(api); this.metaForm.patchValue({ convention: api.convention, name: api.name, @@ -619,6 +667,10 @@ export class UploadComponent implements OnInit { contactTechnicalEntity: api.contactTechnicalEntity, contactTechnicalEmail: api.contactTechnicalEmail, }); + /* Verrouiller les champs qui définissent l'identité de l'API */ + this.metaForm.get('convention')?.disable(); + this.metaForm.get('name')?.disable(); + this.metaForm.get('type')?.disable(); } categoryLabel(cat: Category): string { @@ -634,7 +686,10 @@ export class UploadComponent implements OnInit { this.detectedMainFilename.set(null); this.validationErrors.set([]); this.validationDone.set(false); - this.metaForm.get('type')?.enable(); + /* Ne pas réactiver le type s'il est verrouillé par fromApi */ + if (!this.fromApi()) { + this.metaForm.get('type')?.enable(); + } const input = event.target as HTMLInputElement; const fileList = input.files; @@ -671,6 +726,7 @@ export class UploadComponent implements OnInit { this.INFO_FIELDS.forEach((f) => this.metaForm.get(f)?.markAsTouched()); return; } + if (this.versionAlreadyExists()) return; this.step.set(3); } @@ -698,19 +754,26 @@ export class UploadComponent implements OnInit { this.validationDone.set(true); if (!result.valid) { this.validationErrors.set(result.errors); - this.metaForm.get('type')?.enable(); + /* Ne pas réactiver le type s'il est verrouillé par fromApi */ + if (!this.fromApi()) { + this.metaForm.get('type')?.enable(); + } } else { this.validationErrors.set([]); if (result.type) { this.detectedType.set(result.type); - this.metaForm.patchValue({ type: result.type }); - this.metaForm.get('type')?.disable(); + /* Ne pas modifier le type s'il est verrouillé par fromApi */ + if (!this.fromApi()) { + this.metaForm.patchValue({ type: result.type }); + this.metaForm.get('type')?.disable(); + } } if (result.mainFile) { this.detectedMainFilename.set(result.mainFile); } /* Pré-remplissage du formulaire depuis les métadonnées du fichier */ - if (result.name) this.metaForm.patchValue({ name: result.name }); + /* Ne pas écraser le nom s'il est verrouillé par fromApi */ + if (result.name && !this.fromApi()) this.metaForm.patchValue({ name: result.name }); if (result.versionMajor !== null) this.metaForm.patchValue({ versionMajor: result.versionMajor }); if (result.versionMinor !== null) this.metaForm.patchValue({ versionMinor: result.versionMinor }); if (result.versionPatch !== null) this.metaForm.patchValue({ versionPatch: result.versionPatch }); @@ -781,13 +844,13 @@ export class UploadComponent implements OnInit { if (/^openapi\s*:/m.test(content)) { this.detectedType.set('OPENAPI'); this.detectedMainFilename.set(file.name); - this.metaForm.patchValue({ type: 'OPENAPI' }); + if (!this.fromApi()) this.metaForm.patchValue({ type: 'OPENAPI' }); return; } if (/^asyncapi\s*:/m.test(content)) { this.detectedType.set('ASYNCAPI'); this.detectedMainFilename.set(file.name); - this.metaForm.patchValue({ type: 'ASYNCAPI' }); + if (!this.fromApi()) this.metaForm.patchValue({ type: 'ASYNCAPI' }); return; } }