test(apis): ajouter tests unitaires ApisService + fix upload nouvelle version
- 21 nouveaux tests couvrant create, findOneOrThrow, remove, findVersionsOf, setCurrent et regenerate (pattern vi.fn() sans NestJS TestingModule) - apis.service.ts : ConflictException sur doublon de version (name+convention+version) - upload.component.ts : verrouillage convention/name/type lors d'une nouvelle version, bannière d'info, alerte et blocage si version déjà existante Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -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<string, unknown> = {}) {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ApiEntry> {
|
||||
// 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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- Étape 2 : Informations -->
|
||||
@if (step() === 2) {
|
||||
<!-- Bannière nouvelle version -->
|
||||
@if (fromApi(); as origin) {
|
||||
<div class="fr-callout fr-callout--blue-cumulus fr-mb-3w">
|
||||
<p class="fr-callout__text">
|
||||
Nouvelle version de : <strong>{{ origin.title }}</strong> (v{{ origin.version }})
|
||||
— le nom et la convention sont verrouillés.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="metaForm" (ngSubmit)="goToStep3()" novalidate>
|
||||
|
||||
<!-- Titre : convention + nom -->
|
||||
@@ -240,7 +250,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
|
||||
<!-- Version X.Y.Z -->
|
||||
<p class="fr-label fr-mb-1w">Version <span style="color:var(--text-default-error)">*</span></p>
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-3w">
|
||||
<div class="fr-grid-row fr-grid-row--gutters fr-mb-1w">
|
||||
<div class="fr-col-4">
|
||||
<div class="fr-input-group" [class.fr-input-group--error]="versionMajorInvalid()">
|
||||
<label class="fr-label" for="version-major-input">Majeure (X)</label>
|
||||
@@ -285,8 +295,18 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (versionAlreadyExists()) {
|
||||
<div class="fr-alert fr-alert--warning fr-mb-3w">
|
||||
<p class="fr-alert__title">Version déjà existante</p>
|
||||
<p>
|
||||
La version {{ formValues().versionMajor }}.{{ formValues().versionMinor }}.{{ formValues().versionPatch }}
|
||||
existe déjà pour cette API. Choisissez un numéro de version différent.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fr-btns-group fr-btns-group--inline-sm">
|
||||
<button type="submit" class="fr-btn">Suivant</button>
|
||||
<button type="submit" class="fr-btn" [disabled]="versionAlreadyExists()">Suivant</button>
|
||||
<button type="button" class="fr-btn fr-btn--secondary" (click)="step.set(1)">
|
||||
Retour
|
||||
</button>
|
||||
@@ -411,7 +431,7 @@ import { ApiEntry, Category, API_CONVENTION_LABELS, ApiConvention } from '@datac
|
||||
</div>
|
||||
<div class="fr-col-12 fr-col-md-6">
|
||||
<dt class="fr-text--bold">Type</dt>
|
||||
<dd>{{ metaForm.value.type }}</dd>
|
||||
<dd>{{ displayType() }}</dd>
|
||||
</div>
|
||||
<div class="fr-col-12">
|
||||
<dt class="fr-text--bold">Titre</dt>
|
||||
@@ -499,6 +519,10 @@ export class UploadComponent implements OnInit {
|
||||
validationDone = signal(false);
|
||||
validationErrors = signal<Array<{ file: string; message: string }>>([]);
|
||||
categories = signal<Category[]>([]);
|
||||
/* API d'origine lors d'une création de nouvelle version (from=<id>) */
|
||||
fromApi = signal<ApiEntry | null>(null);
|
||||
/* Versions existantes de l'API d'origine (pour détecter les doublons) */
|
||||
existingVersions = signal<ApiEntryListItem[]>([]);
|
||||
|
||||
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);
|
||||
/* 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);
|
||||
/* 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);
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user