mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.2): bibliothèque de documents (FileStorage réel) + analytics
- FileStorage : putObject/getObjectStream/removeObject, bucket créé au démarrage — MinIO confiné à son implémentation (règle ESLint intacte) - documents : upload multipart (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis, permission d'édition sur la CIBLE), liste filtrable, téléchargement STREAMÉ par l'API (MinIO jamais exposé), suppression ; e2e : octets téléchargés identiques aux octets envoyés - analytics : GET /analytics/summary dérivé du réel — coûts/mois (mouvements + main-d'œuvre figés), pannes par organe (bilans codés), taux de préventif, durée moyenne de résolution, top équipements - générateur OpenAPI : query params, multipart, réponse binaire (71 ops) - CI : service MinIO (bitnami) sur les jobs api et e2e - test de régression du tri « Interventions récentes » rendu déterministe (positions absolues instables sous 12 suites parallèles) ; 58 tests, 6 runs complets consécutifs verts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* E2E R3.2 — bibliothèque (upload MinIO via FileStorage, download streamé,
|
||||
* refus typés, permission sur la cible) + analytics (dérivé du réel).
|
||||
* Nécessite MinIO (infra locale / service CI).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
const PNG_1PX = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
describe('Bibliothèque & analytics (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let nadia: string;
|
||||
let karim: string; // Demandeur : aucune permission d'édition
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `doc-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
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;
|
||||
};
|
||||
nadia = await login('Gestionnaire');
|
||||
karim = await login('Demandeur');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.document.deleteMany({ where: { fileName: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('upload → liste filtrée → download identique → suppression', async () => {
|
||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
|
||||
const envoye = await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'CERTIFICATE')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', PNG_1PX, { filename: `certificat-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(201);
|
||||
expect(envoye.body.assetReference).toBe('A1');
|
||||
expect(envoye.body.size).toBe(PNG_1PX.length);
|
||||
|
||||
const liste = await http()
|
||||
.get(`/documents?assetId=${a1.id}&kind=CERTIFICATE`)
|
||||
.set(auth(nadia))
|
||||
.expect(200);
|
||||
expect(
|
||||
liste.body.documents.some((d: { fileName: string }) => d.fileName.includes(suffix)),
|
||||
).toBe(true);
|
||||
|
||||
const telecharge = await http()
|
||||
.get(`/documents/${envoye.body.id}/download`)
|
||||
.set(auth(nadia))
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
expect(Buffer.compare(telecharge.body as Buffer, PNG_1PX)).toBe(0); // octets identiques
|
||||
|
||||
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
||||
});
|
||||
|
||||
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'OTHER')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', Buffer.from('binaire'), { filename: `x-${suffix}.exe`, contentType: 'application/octet-stream' })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'PHOTO')
|
||||
.attach('file', PNG_1PX, { filename: `orphelin-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(400); // aucun rattachement
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'PHOTO')
|
||||
.field('assetId', '00000000-0000-4000-8000-000000000000')
|
||||
.attach('file', PNG_1PX, { filename: `fantome-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(400);
|
||||
// Karim (Demandeur) : lecture de la bibliothèque OK, édition refusée
|
||||
await http().get('/documents').set(auth(karim)).expect(200);
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(karim))
|
||||
.field('kind', 'PHOTO')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', PNG_1PX, { filename: `interdit-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => {
|
||||
const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200);
|
||||
const s = res.body;
|
||||
expect(s.costsByMonth).toHaveLength(6);
|
||||
// Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci
|
||||
expect(s.monthCost).toBeGreaterThanOrEqual(505);
|
||||
// Pannes par organe : OT-0332 (seed) a un bilan « Guides »
|
||||
expect(
|
||||
s.failuresByComponent.some((f: { label: string }) => f.label === 'Guides'),
|
||||
).toBe(true);
|
||||
// Top équipements : A1 porte les coûts du seed
|
||||
expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true);
|
||||
expect(s.closed12m.total).toBeGreaterThanOrEqual(1);
|
||||
// Karim (Demandeur, sans ANALYTICS) → 403
|
||||
await http().get('/analytics/summary').set(auth(karim)).expect(403);
|
||||
});
|
||||
});
|
||||
@@ -140,19 +140,20 @@ describe('Exploitation (e2e)', () => {
|
||||
.set(auth(salma))
|
||||
.expect(200);
|
||||
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
|
||||
const urgencesActives = listeSalma.workOrders.filter(
|
||||
(w: { priority: string; status: string }) =>
|
||||
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
|
||||
).length;
|
||||
expect(position).toBeGreaterThanOrEqual(0);
|
||||
// Tolérance : les autres specs Jest créent des OT en parallèle (updatedAt
|
||||
// plus récent). L'invariant déterministe : notre clôture précède TOUJOURS
|
||||
// l'ancien Terminé du seed — plus jamais reléguée en queue de liste.
|
||||
expect(position).toBeLessThanOrEqual(urgencesActives + 4);
|
||||
const positionAncienDone = listeSalma.workOrders.findIndex(
|
||||
(w: { reference: string }) => w.reference === `OT-${new Date().getFullYear()}-0332`,
|
||||
// Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
|
||||
// les positions absolues sont donc instables) : notre OT fraîchement
|
||||
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
|
||||
// « par statut », tout En cours passait devant tous les Terminés.
|
||||
const annee = new Date().getFullYear();
|
||||
const posGrilleSeed = listeSalma.workOrders.findIndex(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
|
||||
);
|
||||
expect(position).toBeLessThan(positionAncienDone);
|
||||
const posAncienDone = listeSalma.workOrders.findIndex(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
|
||||
);
|
||||
expect(position).toBeLessThan(posGrilleSeed);
|
||||
expect(position).toBeLessThan(posAncienDone);
|
||||
|
||||
// 7. Terminal : plus aucune transition
|
||||
await http()
|
||||
|
||||
Reference in New Issue
Block a user