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:
z3n
2026-06-24 12:55:03 +00:00
parent 3b9ccecd69
commit b6a52ce554
3 changed files with 285 additions and 15 deletions

View File

@@ -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);
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;
}
}