diff --git a/back/src/modules/apis/apis.service.spec.ts b/back/src/modules/apis/apis.service.spec.ts new file mode 100644 index 0000000..a2dd104 --- /dev/null +++ b/back/src/modules/apis/apis.service.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ApisService } from './apis.service'; +import { ApiType } from '@datacat/shared'; + +/** Accès à la méthode privée pour les tests unitaires */ +function parse(raw: string) { + return (ApisService.prototype as any).parseSearchTokens.call({}, raw) as { + text: string; + type?: ApiType; + version?: string; + latestOnly: boolean; + }; +} + +describe('ApisService.parseSearchTokens()', () => { + describe('chaîne vide', () => { + it('retourne text vide, pas de type, pas de version, latestOnly=false', () => { + expect(parse('')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false }); + }); + + it('gère les espaces seuls', () => { + expect(parse(' ')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: false }); + }); + }); + + describe('texte libre seul (sans version ni type)', () => { + it('"auth" → latestOnly=true automatique', () => { + expect(parse('auth')).toEqual({ text: 'auth', type: undefined, version: undefined, latestOnly: true }); + }); + + it('"paiement commande" → texte libre multi-mots, latestOnly=true', () => { + expect(parse('paiement commande')).toEqual({ + text: 'paiement commande', + type: undefined, + version: undefined, + latestOnly: true, + }); + }); + }); + + describe('détection du type', () => { + it('"async" → ASYNCAPI', () => { + const r = parse('async'); + expect(r.type).toBe('ASYNCAPI'); + }); + + it('"asyncapi" → ASYNCAPI', () => { + expect(parse('asyncapi').type).toBe('ASYNCAPI'); + }); + + it('"openapi" → OPENAPI', () => { + expect(parse('openapi').type).toBe('OPENAPI'); + }); + + it('"open" → OPENAPI', () => { + expect(parse('open').type).toBe('OPENAPI'); + }); + + it('type seul → latestOnly=true (champ non vide, pas de version)', () => { + expect(parse('async').latestOnly).toBe(true); + }); + + it('mots de type ne font pas partie du texte libre', () => { + expect(parse('auth async').text).toBe('auth'); + }); + }); + + describe('détection de version', () => { + it('"v1" → version="1", latestOnly=false', () => { + expect(parse('v1')).toEqual({ text: '', type: undefined, version: '1', latestOnly: false }); + }); + + it('"1.2" → version="1.2"', () => { + expect(parse('1.2').version).toBe('1.2'); + }); + + it('"v1.2.3" → version="1.2.3" (préfixe v supprimé)', () => { + expect(parse('v1.2.3').version).toBe('1.2.3'); + }); + + it('"1.0.0" → version exacte', () => { + expect(parse('1.0.0').version).toBe('1.0.0'); + }); + + it('version présente → latestOnly=false', () => { + expect(parse('auth v1').latestOnly).toBe(false); + }); + + it('numéro de version ne fait pas partie du texte libre', () => { + expect(parse('auth v1').text).toBe('auth'); + }); + }); + + describe('mot-clé "latest"', () => { + it('"latest" seul → latestOnly=true, pas de texte', () => { + expect(parse('latest')).toEqual({ text: '', type: undefined, version: undefined, latestOnly: true }); + }); + + it('"latest" + type → latestOnly=true + type détecté', () => { + const r = parse('latest async'); + expect(r.latestOnly).toBe(true); + expect(r.type).toBe('ASYNCAPI'); + }); + + it('"latest" + version → les deux flags actifs', () => { + const r = parse('latest v1'); + expect(r.latestOnly).toBe(true); + expect(r.version).toBe('1'); + }); + }); + + describe('combinaisons', () => { + it('"auth async" → texte=auth, type=ASYNCAPI, latestOnly=true', () => { + expect(parse('auth async')).toEqual({ + text: 'auth', + type: 'ASYNCAPI', + version: undefined, + latestOnly: true, + }); + }); + + it('"auth async v1" → texte=auth, type=ASYNCAPI, version=1, latestOnly=false', () => { + expect(parse('auth async v1')).toEqual({ + text: 'auth', + type: 'ASYNCAPI', + version: '1', + latestOnly: false, + }); + }); + + it('"auth 1.0.0" → texte=auth, version=1.0.0, latestOnly=false', () => { + expect(parse('auth 1.0.0')).toEqual({ + text: 'auth', + type: undefined, + version: '1.0.0', + latestOnly: false, + }); + }); + + it('"openapi latest" → type=OPENAPI, latestOnly=true', () => { + expect(parse('openapi latest')).toEqual({ + text: '', + type: 'OPENAPI', + version: undefined, + latestOnly: true, + }); + }); + + it('insensible à la casse pour les mots-clés', () => { + const r = parse('ASYNC Auth LATEST'); + expect(r.type).toBe('ASYNCAPI'); + expect(r.latestOnly).toBe(true); + expect(r.text).toBe('Auth'); + }); + }); +}); diff --git a/back/src/modules/apis/apis.service.ts b/back/src/modules/apis/apis.service.ts index 87d77d7..1f81fa3 100644 --- a/back/src/modules/apis/apis.service.ts +++ b/back/src/modules/apis/apis.service.ts @@ -49,39 +49,107 @@ export class ApisService { } async findAll(query: ApiListQuery): Promise { - const page = Math.max(1, Number(query.page ?? 1)); + const page = Math.max(1, Number(query.page ?? 1)); const limit = Math.min(100, Math.max(1, Number(query.limit ?? 20))); const offset = (page - 1) * limit; const qb = this.repo.createQueryBuilder('api'); - if (query.type) { - qb.andWhere('api.type = :type', { type: query.type as ApiType }); + let effectiveType = query.type as ApiType | undefined; + let version: string | undefined; + let latestOnly = false; + + if (query.search?.trim()) { + const parsed = this.parseSearchTokens(query.search); + // Le type détecté dans la saisie override le select UI + if (parsed.type) effectiveType = parsed.type; + version = parsed.version; + latestOnly = parsed.latestOnly; + + if (parsed.text) { + // Recherche fuzzy via pg_trgm + ILIKE pour les matches partiels courts + qb.andWhere( + `( + api.name ILIKE :likeText + OR api.provider ILIKE :likeText + OR COALESCE(api.description, '') ILIKE :likeText + OR api.contact_functional_name ILIKE :likeText + OR api.contact_technical_name ILIKE :likeText + OR word_similarity(:text, api.name) > 0.5 + OR word_similarity(:text, api.provider) > 0.5 + OR word_similarity(:text, COALESCE(api.description, '')) > 0.5 + )`, + { likeText: `%${parsed.text}%`, text: parsed.text }, + ); + } } - if (query.search) { - qb.andWhere( - `(LOWER(api.name) LIKE :search - OR LOWER(api.provider) LIKE :search - OR LOWER(COALESCE(api.description, '')) LIKE :search - OR LOWER(api.contact_functional_name) LIKE :search - OR LOWER(api.contact_technical_name) LIKE :search)`, - { search: `%${query.search.toLowerCase()}%` }, - ); + + if (effectiveType) { + qb.andWhere('api.type = :type', { type: effectiveType }); } if (query.categoryId) { qb.andWhere('api.category_id = :categoryId', { categoryId: query.categoryId }); } - qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit); + // Filtre par version (partielle ou complète) + if (version) { + const parts = version.split('.').map(Number); + if (parts.length >= 1 && !isNaN(parts[0])) + qb.andWhere('api.version_major = :major', { major: parts[0] }); + if (parts.length >= 2 && !isNaN(parts[1])) + qb.andWhere('api.version_minor = :minor', { minor: parts[1] }); + if (parts.length >= 3 && !isNaN(parts[2])) + qb.andWhere('api.version_patch = :patch', { patch: parts[2] }); + } + // Latest-only : exclure les entrées dont il existe une version plus récente du même API + if (latestOnly) { + qb.andWhere(`NOT EXISTS ( + SELECT 1 FROM api_entries e2 + WHERE e2.name = api.name AND e2.convention = api.convention + AND ( + e2.version_major > api.version_major + OR (e2.version_major = api.version_major AND e2.version_minor > api.version_minor) + OR (e2.version_major = api.version_major AND e2.version_minor = api.version_minor + AND e2.version_patch > api.version_patch) + ) + )`); + } + + qb.orderBy('api.created_at', 'DESC').skip(offset).take(limit); const [entities, total] = await qb.getManyAndCount(); - return { - items: entities.map(toApiEntryListItem), - total, - page, - limit, + return { items: entities.map(toApiEntryListItem), total, page, limit }; + } + + private parseSearchTokens(raw: string): { + text: string; + type?: ApiType; + version?: string; + latestOnly: boolean; + } { + const TYPE_MAP: Record = { + async: 'ASYNCAPI', asyncapi: 'ASYNCAPI', + openapi: 'OPENAPI', open: 'OPENAPI', }; + const VERSION_RE = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/i; + let type: ApiType | undefined; + let version: string | undefined; + let latestOnly = false; + const remaining: string[] = []; + + for (const word of raw.trim().split(/\s+/).filter(Boolean)) { + const lower = word.toLowerCase(); + if (lower === 'latest') { latestOnly = true; } + else if (TYPE_MAP[lower]) { type = TYPE_MAP[lower]; } + else if (VERSION_RE.test(word)) { version = word.replace(/^v/i, ''); } + else { remaining.push(word); } + } + + const text = remaining.join(' '); + // Pas de version explicite ET champ non vide → latest par défaut + if (!latestOnly && raw.trim().length > 0 && !version) latestOnly = true; + return { text, type, version, latestOnly }; } async findOneOrThrow(id: string): Promise { diff --git a/back/src/modules/seeder/seeder.service.ts b/back/src/modules/seeder/seeder.service.ts index a2de0eb..95ffb18 100644 --- a/back/src/modules/seeder/seeder.service.ts +++ b/back/src/modules/seeder/seeder.service.ts @@ -1,6 +1,6 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, DataSource } from 'typeorm'; import * as fs from 'fs/promises'; import { ApiEntryEntity } from '../apis/api-entry.entity'; import { CategoryEntity } from '../categories/category.entity'; @@ -593,9 +593,13 @@ export class SeederService implements OnApplicationBootstrap { @InjectRepository(CategoryEntity) private readonly categoryRepo: Repository, private readonly docsService: DocsGenerationService, + private readonly dataSource: DataSource, ) {} async onApplicationBootstrap(): Promise { + // Activer l'extension pg_trgm pour la recherche fuzzy + await this.dataSource.query('CREATE EXTENSION IF NOT EXISTS pg_trgm'); + const forceReseed = process.env.FORCE_RESEED === 'true'; if (forceReseed) { await this.reset(); diff --git a/back/vitest.config.ts b/back/vitest.config.ts new file mode 100644 index 0000000..3566066 --- /dev/null +++ b/back/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts'], + }, +}); diff --git a/front-public/src/app/pages/browse/browse.component.ts b/front-public/src/app/pages/browse/browse.component.ts index 1e5b379..af0e815 100644 --- a/front-public/src/app/pages/browse/browse.component.ts +++ b/front-public/src/app/pages/browse/browse.component.ts @@ -74,7 +74,7 @@ import { CategoryDeleteModalComponent } from '../../shared/category-delete-modal @@ -365,10 +365,11 @@ export class BrowseComponent implements OnInit { loadSearch() { this.searchLoading.set(true); - const q = this.searchQuery().trim().toLowerCase(); + const raw = this.searchQuery(); + const q = raw.trim().toLowerCase(); forkJoin({ - apis: this.apiService.list({ search: this.searchQuery(), page: this.searchPage(), limit: this.searchLimit }), + apis: this.apiService.list({ search: raw || undefined, page: this.searchPage(), limit: this.searchLimit }), categories: this.categoryService.list(), }).subscribe({ next: ({ apis, categories }) => { @@ -376,9 +377,7 @@ export class BrowseComponent implements OnInit { this.searchTotal.set(apis.total); this.searchCategoryResults.set( categories.items.filter( - (c) => - c.name.toLowerCase().includes(q) || - (c.description ?? '').toLowerCase().includes(q), + (c) => c.name.toLowerCase().includes(q) || (c.description ?? '').toLowerCase().includes(q), ), ); this.searchLoading.set(false); diff --git a/front-public/src/app/pages/catalog/catalog.component.ts b/front-public/src/app/pages/catalog/catalog.component.ts index db543a6..10d1792 100644 --- a/front-public/src/app/pages/catalog/catalog.component.ts +++ b/front-public/src/app/pages/catalog/catalog.component.ts @@ -41,7 +41,7 @@ import { ApiEntryListItem, ApiType } from '@datacat/shared'; id="search-input" class="fr-input" type="search" - placeholder="Rechercher par titre ou description..." + placeholder="Ex : auth async v1.0, openapi latest..." [ngModel]="search()" (ngModelChange)="onSearchChange($event)" />