/** * E2E R5.2 — assistant au contrat : le service IA reste interne, l'API porte * l'auth et la matrice ; le dialecte interne est traduit vers @siop/shared. * Le service IA est joué par un STUB HTTP local (la vraie chaîne se vérifie * en recette réelle — convention R5.1). */ process.env.DEMO_MODE = 'true'; import { createServer, type Server } from 'node:http'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { AppModule } from '../src/app.module'; const REPONSE_ASK = { mode: 'extractif', answer: null, extraits: [ { source_type: 'DOCUMENT', document_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df001', work_order_id: null, titre: 'Notice Gen2.pdf', locator: 'p. 42', content: 'Serrer les coulisseaux au couple de 25 N·m.', score: 0.61, }, ], corpus: { documents: 6, bilans: 214 }, }; const REPONSE_SUGGEST = { suggestions: [ { field: 'ANOMALY', value_id: '7d7bfa5c-2f43-4f9e-9e59-3c1f0a5df002', label: 'Cellule/barrière encrassée', confidence: 'FORTE', similar_reports: 9, score: 0.62, }, ], }; const REPONSE_REINDEX = { documents_indexes: 6, documents_ignores: 2, bilans_indexes: 214, extraits: 180, }; describe('Assistant (e2e — stub du service IA)', () => { let app: INestApplication; let stub: Server; let ahmed: string; // Technicien : view + edit sur WORK_ORDERS, ASSETS en lecture let karim: string; // Demandeur : aucun droit WORK_ORDERS let rachid: string; // Vue seule : view sans edit let nadia: string; // Gestionnaire : ASSETS.edit — administre le corpus const requetesRecues: { url: string; jeton: string | undefined }[] = []; const http = () => request(app.getHttpServer()); const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); beforeAll(async () => { // Stub du service IA sur un port éphémère stub = createServer((req, res) => { requetesRecues.push({ url: req.url ?? '', jeton: req.headers['x-service-token'] as string | undefined, }); res.setHeader('Content-Type', 'application/json'); if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK)); else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST)); else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX)); else { res.statusCode = 404; res.end('{}'); } }); await new Promise((resolve) => stub.listen(0, '127.0.0.1', resolve)); const adresse = stub.address(); const port = typeof adresse === 'object' && adresse ? adresse.port : 0; process.env.AI_SERVICE_URL = `http://127.0.0.1:${port}`; process.env.AI_SERVICE_TOKEN = 'jeton-de-test'; const moduleRef = await Test.createTestingModule({ imports: [AppModule.forRoot()], }).compile(); app = moduleRef.createNestApplication(); await app.init(); const { body } = await http().get('/auth/demo-accounts'); const login = async (roleName: string) => { const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName); return (await http().post('/auth/demo-login').send({ userId: compte.id })).body .accessToken as string; }; ahmed = await login('Technicien'); karim = await login('Demandeur'); rachid = await login('Vue seule'); nadia = await login('Gestionnaire'); }); afterAll(async () => { await app?.close(); await new Promise((resolve) => stub.close(() => resolve())); }); it('ask : traduit le dialecte interne vers le contrat, avec le jeton de service', async () => { const res = await http() .post('/assistant/ask') .set(auth(ahmed)) .send({ question: 'quel couple de serrage pour les guides ?' }) .expect(200); expect(res.body.mode).toBe('EXTRACTIVE'); expect(res.body.excerpts[0]).toMatchObject({ sourceType: 'DOCUMENT', title: 'Notice Gen2.pdf', locator: 'p. 42', }); expect(res.body.corpus).toEqual({ documents: 6, reports: 214 }); const derniere = requetesRecues.at(-1)!; expect(derniere.url).toBe('/internal/ask'); expect(derniere.jeton).toBe('jeton-de-test'); // ADR-004 §4 }); it('suggest-bilan : codes existants traduits (FORTE → HIGH, snake → camel)', async () => { const res = await http() .post('/assistant/suggest-bilan') .set(auth(ahmed)) .send({ description: 'porte cabine qui rebondit, cellule encrassée, nettoyage fait' }) .expect(200); expect(res.body.suggestions[0]).toMatchObject({ field: 'ANOMALY', confidence: 'HIGH', similarReports: 9, }); }); it('la matrice s’applique : demandeur sans WORK_ORDERS → 403 sur ask', async () => { await http() .post('/assistant/ask') .set(auth(karim)) .send({ question: 'où sont les notices ?' }) .expect(403); }); it('vue seule : ask autorisé (view), suggest refusé (edit requis — D1)', async () => { await http() .post('/assistant/ask') .set(auth(rachid)) .send({ question: 'historique du parc ?' }) .expect(200); await http() .post('/assistant/suggest-bilan') .set(auth(rachid)) .send({ description: 'une description suffisamment longue ici' }) .expect(403); }); it('reindex : traduit le bilan d’indexation, réservé à ASSETS.edit (D3)', async () => { const res = await http().post('/assistant/reindex').set(auth(nadia)).expect(200); expect(res.body).toEqual({ documentsIndexed: 6, documentsSkipped: 2, reportsIndexed: 214, chunks: 180, }); expect(requetesRecues.at(-1)!.url).toBe('/internal/reindex'); // Ahmed (Technicien) lit le parc mais n'administre pas le corpus await http().post('/assistant/reindex').set(auth(ahmed)).expect(403); }); it('question trop courte : 400 avant tout appel au service IA', async () => { const avant = requetesRecues.length; await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400); expect(requetesRecues.length).toBe(avant); }); it('service IA éteint : 503 propre, jamais un 500', async () => { await new Promise((resolve) => stub.close(() => resolve())); await http() .post('/assistant/ask') .set(auth(ahmed)) .send({ question: 'le service est-il là ?' }) .expect(503); }); });