Files
siop2/apps/api/test/preventif.e2e-spec.ts
pr-daaif 6a2dae1710 feat(r2.3): écrans web exploitation — OT, demandes, préventif, compteurs, urgence
- liste OT (filtres, strie rouge, « immédiat ») ; fiche OT : transitions
  pilotées par allowedTransitions, clôture grisée avec la garde expliquée,
  bilan codé (6 selects sur référentiels), checklist Fait→N-A→à faire,
  activité + commentaires, assignation, annulation motivée
- nouvel OT : interrupteur « personne bloquée » qui force la priorité
- demandes : table + panneau d'approbation (priorité, assignation,
  approuver → fiche OT), rejet en modale à motif obligatoire, signalement
  interne ; statut « Résolue » dérivé de l'OT lié
- préventif : tuiles réelles, générer + résumé (« regénérer ne double
  rien »), gabarits administrables ; compteurs : saisie + historique
- tableau de bord réel : bandeau urgence cliquable, KPIs, OT par statut,
  interventions récentes ; accueil dédié aux rôles sans exploitation
- urgence traversante : chip topbar pulsante (60 s), badges de nav
- GET /assets/options (auth seule) : le Demandeur peut désigner l'appareil
  qu'il signale — trou débusqué par l'e2e (49 opérations au contrat)
- génération préventive durcie : collision de référence RETENTÉE (plus de
  saut silencieux), P2003 toléré ; 3 runs Jest complets consécutifs verts
- 9 tests Playwright (recette R2 officielle rejouée intégralement),
  50 tests API (94,6 % / 78,9 %)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:13:20 +01:00

209 lines
8.6 KiB
TypeScript

/**
* E2E R2.2 — préventif : génération IDEMPOTENTE (l'unicité est en base),
* premier contrôle (tout), périodicités ancrées sur la mise en service ;
* compteurs strictement croissants.
* Les générations de test utilisent des mois lointains (2031) puis sont purgées.
*/
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';
describe('Préventif & compteurs (e2e)', () => {
let app: INestApplication;
let admin: string;
let ahmed: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
beforeAll(async () => {
await seed(prisma);
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
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;
};
admin = await login('Administrateur');
ahmed = await login('Technicien');
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
await prisma.taskTemplate.deleteMany({ where: { label: { contains: 'TEST-' } } });
await prisma.meterReading.deleteMany({ where: { value: { gte: 90000000 } } });
await app?.close();
await prisma.$disconnect();
});
// Les assertions portent sur les 8 appareils du SEED : d'autres specs Jest
// créent/détruisent des appareils en parallèle sur la même base.
const SEED_REFS = ['A1', 'A2', 'B1', 'B2', 'C1', 'D1', 'E2', 'M1'];
const grillesSeed = (period: string) =>
prisma.workOrder.count({
where: { periodKey: period, asset: { reference: { in: SEED_REFS } } },
});
it('génère une grille par appareil sous contrat — et regénérer ne double RIEN', async () => {
const premiere = await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-01' })
.expect(200);
expect(premiere.body.created).toBeGreaterThanOrEqual(SEED_REFS.length);
expect(premiere.body.references.length).toBe(premiere.body.created);
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length);
const seconde = await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-01' })
.expect(200);
expect(seconde.body.skipped).toBeGreaterThanOrEqual(SEED_REFS.length);
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length); // toujours 8, pas 16
});
it('premier contrôle (toutes les tâches) pour un appareil sans historique ; grille normale sinon', async () => {
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
const mensuelles = await prisma.taskTemplate.count({
where: { isActive: true, periodMonths: 1 },
});
// Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher)
const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } });
const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } });
const vierge = await prisma.asset.create({
data: {
reference: `PVT-${Date.now().toString(36)}`,
brand: 'Test',
categoryId: categorie.id,
locationId: site.id,
},
});
try {
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-09' })
.expect(200);
const grille = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: vierge.id, periodKey: '2031-09' } },
include: { _count: { select: { checklist: true } } },
});
expect(grille.title).toContain('Premier contrôle');
expect(grille._count.checklist).toBe(templatesActifs);
} finally {
await prisma.workOrder.deleteMany({ where: { assetId: vierge.id } });
await prisma.asset.delete({ where: { id: vierge.id } });
}
// A1 a toujours un historique préventif (seed) → grille normale.
// Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois
// ni du 3, ni du 6, ni du 12 → seules les tâches MENSUELLES sont dues.
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },
include: { _count: { select: { checklist: true } } },
});
expect(grilleA1.title).toContain('Grille du mois');
expect(grilleA1._count.checklist).toBe(mensuelles);
});
it('mois anniversaire de mise en service : les périodicités longues tombent (mars 2031 pour A1)', async () => {
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-03' })
.expect(200);
// écart mars 2020 → mars 2031 = 132 mois : divisible par 3, 6 et 12
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
const grille = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-03' } },
include: { checklist: true },
});
expect(grille.checklist.length).toBe(templatesActifs);
expect(grille.checklist.some((c) => c.label === 'Essai du parachute')).toBe(true);
});
it('gabarits : ajout, doublon 409, désactivation (exclu des générations suivantes), 404', async () => {
const cree = await http()
.post('/preventive/templates')
.set(auth(admin))
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
.expect(201);
await http()
.post('/preventive/templates')
.set(auth(admin))
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
.expect(409);
await http()
.patch(`/preventive/templates/${cree.body.id}`)
.set(auth(admin))
.send({ isActive: false })
.expect(200);
await http()
.patch('/preventive/templates/00000000-0000-4000-8000-000000000000')
.set(auth(admin))
.send({ label: 'X' })
.expect(404);
// désactivé → absent de la génération d'un nouveau mois
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-05' })
.expect(200);
const labels = await prisma.checklistItem.findMany({
where: { workOrder: { periodKey: '2031-05' } },
select: { label: true },
});
expect(labels.some((l) => l.label.startsWith('TEST-'))).toBe(false);
// le statut du mois courant répond (structure)
const status = await http().get('/preventive/status').set(auth(ahmed)).expect(200);
expect(status.body).toHaveProperty('generated');
expect(status.body).toHaveProperty('late');
});
it('compteurs : relevé croissant accepté, régression refusée, appareil inconnu 404', async () => {
const { body: assets } = await http().get('/assets').set(auth(ahmed));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const liste = await http().get(`/assets/${a1.id}/meters`).set(auth(ahmed)).expect(200);
expect(liste.body.meters).toHaveLength(2); // les 2 compteurs, même vides
const heures = liste.body.meters.find((m: { kind: string }) => m.kind === 'RUNNING_HOURS');
const dernier = heures.readings[0]?.value ?? 0;
const apres = await http()
.post(`/assets/${a1.id}/meter-readings`)
.set(auth(ahmed))
.send({ kind: 'RUNNING_HOURS', value: 90000000 + dernier })
.expect(201);
const majHeures = apres.body.meters.find(
(m: { kind: string }) => m.kind === 'RUNNING_HOURS',
);
expect(majHeures.readings[0].value).toBe(90000000 + dernier);
expect(majHeures.readings[0].readBy.displayName).toBe('Ahmed Benali');
await http()
.post(`/assets/${a1.id}/meter-readings`)
.set(auth(ahmed))
.send({ kind: 'RUNNING_HOURS', value: dernier })
.expect(400);
await http()
.get('/assets/00000000-0000-4000-8000-000000000000/meters')
.set(auth(ahmed))
.expect(404);
});
});