/* * Tests de non-régression du registre d'applications. * Le registre doit faire de app.json l'unique source de vérité des applications déployées. */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { discoverApps, refreshApps, refreshAppsIfStale } = require('../src/app-registry'); function writeManifest(basePath, directory, manifest) { const appPath = path.join(basePath, directory); fs.mkdirSync(appPath, { recursive: true }); fs.writeFileSync(path.join(appPath, 'app.json'), JSON.stringify(manifest), 'utf8'); } const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'app-registry-')); try { writeManifest(sandbox, 'contacts', { id: 'itinova-contacts', name: 'Itinova Contacts', category: 'ITINOVA', containerName: 'itinova-contacts', image: 'images/itinova-contacts.png', ai: { engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }], activeConfigurationPath: '/api/ai/active-configuration', }, urls: { recette: 'https://contacts-itinova.recette.santinova-soft.org', prod: 'https://contacts-itinova.santinova-soft.org', }, }); writeManifest(sandbox, 'dashboard', { id: 'manus-dashboard', name: 'Dashboard', category: 'INFRA', urls: { recette: 'https://dashboard-santinova.recette.santinova-soft.org', prod: 'https://dashboard-santinova.santinova-soft.org', }, }); writeManifest(sandbox, 'falc', { id: 'falc-generator', name: 'FALC Generator', category: 'ITINOVA', urls: { recette: 'https://falc-itinova.recette.santinova-soft.org' }, }); writeManifest(sandbox, 'invalide', { id: 'app-invalide', name: 'Application invalide', category: 'INCONNUE', urls: { recette: 'https://invalide.recette.santinova-soft.org' }, }); writeManifest(sandbox, 'ia-invalide', { id: 'app-ia-invalide', name: 'Application IA invalide', category: 'ITINOVA', ai: { engines: [{ provider: 'Google Gemini' }] }, urls: { recette: 'https://ia-invalide.recette.santinova-soft.org' }, }); writeManifest(sandbox, 'tarif-ia-invalide', { id: 'app-tarif-ia-invalide', name: 'Tarif IA invalide', category: 'ITINOVA', ai: { engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', billing: 'inconnu' }] }, urls: { recette: 'https://tarif-ia-invalide.recette.santinova-soft.org' }, }); const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' }); assert.equal(discovery.apps.length, 3, 'un manifeste invalide doit être exclu'); assert.equal(discovery.errors.length, 3, 'une erreur explicite doit être retournée pour chaque manifeste invalide'); assert.ok(discovery.errors.some((error) => error.error.includes('billing doit être free ou paid'))); const app = discovery.apps.find((candidate) => candidate.id === 'itinova-contacts'); assert.equal(app.directory, 'contacts', 'le dossier doit être déduit du chemin du manifeste'); assert.equal(app.healthCheckUrl, app.urls.recette, 'la vérification doit cibler l’environnement courant'); assert.equal(app.containerName, 'itinova-contacts'); assert.deepEqual(app.ai, { engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }], activeConfigurationPath: '/api/ai/active-configuration', }, 'la métadonnée IA descriptive, tarifaire, de provenance et de configuration active doit être conservée'); const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator'); assert.deepEqual(falc.ai, { engines: [ { provider: 'Manus Forge', model: 'modèle par défaut non explicité', role: 'Transformation FALC', billing: 'paid', source: 'application' }, { provider: 'Manus Forge', model: 'gemini-3.1-pro-preview', role: 'Récits de vie', billing: 'paid', source: 'application' }, ], }, 'le catalogue IA doit compléter un manifeste historique sans moteur déclaré'); const dashboardRecette = discovery.apps.find((candidate) => candidate.id === 'manus-dashboard'); assert.equal(dashboardRecette.name, 'Dashboard Recette'); const productionDiscovery = discoverApps({ appsBasePath: sandbox, environment: 'prod' }); const dashboardProduction = productionDiscovery.apps.find((candidate) => candidate.id === 'manus-dashboard'); assert.equal(dashboardProduction.name, 'Dashboard Production'); const config = { appsBasePath: sandbox, environment: 'recette', apps: [{ id: 'itinova-contacts', name: 'ancienne valeur statique' }], }; const refresh = refreshApps(config); assert.equal(refresh.apps.length, 3, 'les applications non déployées ne doivent pas rester en cache'); assert.equal( config.apps.find((candidate) => candidate.id === 'itinova-contacts').name, 'Itinova Contacts', 'app.json doit prévaloir sur toute définition statique' ); // Un déploiement met à jour app.json : le dashboard doit le détecter sans // redémarrage, mais sans relire les manifests à chaque requête. refreshApps(config, console, 1_000); writeManifest(sandbox, 'contacts', { id: 'itinova-contacts', name: 'Itinova Contacts mis à jour', category: 'ITINOVA', urls: { recette: 'https://contacts-itinova.recette.santinova-soft.org', prod: 'https://contacts-itinova.santinova-soft.org', }, }); const cachedRefresh = refreshAppsIfStale(config, { now: 1_999, maxAgeMs: 1_000 }); assert.equal(cachedRefresh.refreshed, false, 'le cache reste utilisable avant son expiration'); assert.equal( config.apps.find((candidate) => candidate.id === 'itinova-contacts').name, 'Itinova Contacts', 'le cache ne doit pas relire les manifests avant son expiration' ); const expiredRefresh = refreshAppsIfStale(config, { now: 2_000, maxAgeMs: 1_000 }); assert.equal(expiredRefresh.refreshed, true, 'le manifeste est relu lorsque le cache expire'); assert.equal( config.apps.find((candidate) => candidate.id === 'itinova-contacts').name, 'Itinova Contacts mis à jour', 'la modification de manifeste doit apparaître sans redémarrage du dashboard' ); console.log('OK app-registry'); } finally { fs.rmSync(sandbox, { recursive: true, force: true }); }