All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 40s
147 lines
6.3 KiB
JavaScript
147 lines
6.3 KiB
JavaScript
/*
|
||
* 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 });
|
||
}
|