/** * E2E R2 — exploitation : machine à états stricte, garde de clôture * (bilan + checklist), demande → OT (1-1), scoping « voir autre ». * Rejoue la recette officielle : demande gardien → approbation → OT → * intervention → bilan codé → clôture → suivi demandeur. */ 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('Exploitation (e2e)', () => { let app: INestApplication; let salma: string; // Dispatcher — voir autre, approuve let ahmed: string; // Technicien — SES OT seulement let karim: string; // Demandeur — SES demandes seulement let ahmedId: string; const prisma = new PrismaClient(); const http = () => request(app.getHttpServer()); const suffix = 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); const res = await http().post('/auth/demo-login').send({ userId: compte.id }); return res.body.accessToken as string; }; salma = await login('Dispatcher'); ahmed = await login('Technicien'); karim = await login('Demandeur'); ahmedId = ( await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } }) ).id; }); afterAll(async () => { await prisma.request.deleteMany({ where: { description: { contains: suffix } } }); await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } }); await app?.close(); await prisma.$disconnect(); }); const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => { // 1. Karim (gardien) signale const { body: assets } = await http().get('/assets').set(auth(salma)); const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); const demande = await http() .post('/requests') .set(auth(karim)) .send({ assetId: a1.id, description: `Porte qui grince (${suffix})` }) .expect(201); expect(demande.body.status).toBe('RECEIVED'); // 2. Salma approuve → OT créé et lié (1-1) const ot = await http() .post(`/requests/${demande.body.id}/approve`) .set(auth(salma)) .send({ title: `Réparer porte qui grince (${suffix})`, priority: 'MEDIUM', assigneeIds: [ahmedId] }) .expect(201); expect(ot.body.status).toBe('OPEN'); expect(ot.body.request.reference).toBe(demande.body.reference); // double approbation → refus await http() .post(`/requests/${demande.body.id}/approve`) .set(auth(salma)) .send({}) .expect(409); // 3. Ahmed démarre, commente const id = ot.body.id; await http() .post(`/work-orders/${id}/transition`) .set(auth(ahmed)) .send({ to: 'IN_PROGRESS' }) .expect(200); await http() .post(`/work-orders/${id}/comments`) .set(auth(ahmed)) .send({ message: 'Charnière usée, graissage effectué.' }) .expect(201); // 4. Clôture SANS bilan → bloquée (garde) const refus = await http() .post(`/work-orders/${id}/transition`) .set(auth(ahmed)) .send({ to: 'DONE' }) .expect(409); expect(refus.body.message).toContain('bilan incomplet'); // 5. Bilan codé (3 champs requis) puis clôture const { body: refs } = await http().get('/reference-values').set(auth(ahmed)); const valeur = (field: string, label: string) => refs.referenceValues.find( (v: { field: string; label: string }) => v.field === field && v.label === label, ).id; await http() .put(`/work-orders/${id}/report`) .set(auth(ahmed)) .send({ doorStateId: valeur('DOOR_STATE', 'Fonctionnement normal'), actionTakenId: valeur('ACTION_TAKEN', 'Nettoyage / graissage'), componentConcernedId: valeur('COMPONENT_CONCERNED', 'Portes'), }) .expect(200); const clos = await http() .post(`/work-orders/${id}/transition`) .set(auth(ahmed)) .send({ to: 'DONE', comment: 'RAS après graissage' }) .expect(200); expect(clos.body.status).toBe('DONE'); expect(clos.body.completedAt).toBeTruthy(); // 6. Karim suit SA demande : l'OT lié est terminé const { body: mesDemandes } = await http().get('/requests').set(auth(karim)).expect(200); const laMienne = mesDemandes.requests.find( (r: { id: string }) => r.id === demande.body.id, ); expect(laMienne.workOrder.status).toBe('DONE'); // 6 bis. Régression recette R2 : l'OT fraîchement clôturé remonte EN TÊTE // de la liste de Salma (après les urgences actives) — il doit apparaître // dans les « Interventions récentes » de tout le monde, pas seulement // chez l'assigné. const { body: listeSalma } = await http() .get('/work-orders') .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); expect(position).toBeLessThanOrEqual(urgencesActives); // 7. Terminal : plus aucune transition await http() .post(`/work-orders/${id}/transition`) .set(auth(ahmed)) .send({ to: 'IN_PROGRESS' }) .expect(409); }); it('machine à états : OPEN → DONE direct interdit ; OPEN → ON_HOLD interdit', async () => { const { body } = await http().get('/work-orders').set(auth(salma)); const ouvert = body.workOrders.find( (w: { status: string; priority: string }) => w.status === 'OPEN' && w.priority === 'PERSON_TRAPPED', ); await http() .post(`/work-orders/${ouvert.id}/transition`) .set(auth(salma)) .send({ to: 'DONE' }) .expect(409); await http() .post(`/work-orders/${ouvert.id}/transition`) .set(auth(salma)) .send({ to: 'ON_HOLD' }) .expect(409); }); it('garde de clôture : la checklist incomplète bloque la grille du mois', async () => { const { body } = await http().get('/work-orders').set(auth(ahmed)); const grille = body.workOrders.find((w: { type: string }) => w.type === 'PREVENTIVE'); const detail = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200); expect(detail.body.closureBlockers.join(' ')).toContain('checklist'); // Régler toutes les tâches (Fait/N-A) → le blocage checklist disparaît for (const [i, item] of detail.body.checklist.entries()) { await http() .patch(`/work-orders/${grille.id}/checklist/${item.id}`) .set(auth(ahmed)) .send({ state: i % 2 ? 'NA' : 'DONE' }) .expect(200); } const apres = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200); expect(apres.body.closureBlockers.join(' ')).not.toContain('checklist'); expect(apres.body.checklist.every((c: { doneBy: unknown }) => c.doneBy)).toBe(true); // remise en état (seed idempotent) for (const item of apres.body.checklist) { await http() .patch(`/work-orders/${grille.id}/checklist/${item.id}`) .set(auth(ahmed)) .send({ state: 'PENDING' }) .expect(200); } }); it('« voir autre » : Ahmed ne voit que SES OT ; Karim que SES demandes', async () => { const { body: tous } = await http().get('/work-orders').set(auth(salma)); const { body: siens } = await http().get('/work-orders').set(auth(ahmed)); expect(tous.workOrders.length).toBeGreaterThan(siens.workOrders.length); expect( siens.workOrders.every((w: { assignees: { id: string }[] }) => w.assignees.some((a) => a.id === ahmedId), ), ).toBe(true); // un OT non assigné à Ahmed lui est invisible (404, pas 403 — pas de fuite) const autre = tous.workOrders.find( (w: { assignees: { id: string }[] }) => !w.assignees.some((a) => a.id === ahmedId), ); await http().get(`/work-orders/${autre.id}`).set(auth(ahmed)).expect(404); const { body: demandes } = await http().get('/requests').set(auth(karim)); expect(demandes.requests.length).toBeGreaterThan(0); // Karim (create seulement) ne peut pas approuver await http() .post(`/requests/${demandes.requests[0].id}/approve`) .set(auth(karim)) .send({}) .expect(403); }); it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => { const { body: assets } = await http().get('/assets').set(auth(salma)); const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1'); const demande = await http() .post('/requests') .set(auth(karim)) .send({ assetId: c1.id, description: `À rejeter (${suffix})` }) .expect(201); await http() .post(`/requests/${demande.body.id}/reject`) .set(auth(salma)) .send({ reason: '' }) .expect(400); const rejetee = await http() .post(`/requests/${demande.body.id}/reject`) .set(auth(salma)) .send({ reason: 'Doublon de la demande précédente.' }) .expect(200); expect(rejetee.body.rejectionReason).toContain('Doublon'); // bilan : une valeur ACTION_TAKEN dans le champ DOOR_STATE → 400 const { body: refs } = await http().get('/reference-values').set(auth(salma)); const action = refs.referenceValues.find((v: { field: string }) => v.field === 'ACTION_TAKEN'); const { body: wos } = await http().get('/work-orders').set(auth(salma)); const enCours = wos.workOrders.find((w: { status: string }) => w.status === 'IN_PROGRESS'); await http() .put(`/work-orders/${enCours.id}/report`) .set(auth(salma)) .send({ doorStateId: action.id }) .expect(400); }); it('« personne bloquée » saute en tête de liste', async () => { const { body } = await http().get('/work-orders').set(auth(salma)); expect(body.workOrders[0].priority).toBe('PERSON_TRAPPED'); }); it('OT manuel : cycle complet En attente ↔ En cours, puis annulation motivée', async () => { const { body: assets } = await http().get('/assets').set(auth(salma)); const e2 = assets.assets.find((a: { reference: string }) => a.reference === 'E2'); const ot = await http() .post('/work-orders') .set(auth(salma)) .send({ title: `Travaux cabine (${suffix})`, type: 'WORKS', priority: 'LOW', assetId: e2.id, dueDate: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(), assigneeIds: [ahmedId], }) .expect(201); const id = ot.body.id; await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200); const attente = await http() .post(`/work-orders/${id}/transition`) .set(auth(salma)) .send({ to: 'ON_HOLD', comment: 'Attente de pièce' }) .expect(200); expect(attente.body.allowedTransitions).toEqual(['IN_PROGRESS', 'CANCELLED']); await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200); const annule = await http() .post(`/work-orders/${id}/transition`) .set(auth(salma)) .send({ to: 'CANCELLED', comment: 'Travaux reportés au trimestre prochain' }) .expect(200); expect(annule.body.cancelledAt).toBeTruthy(); }); it('refus propres : équipement inconnu, personne inconnue, tâche inconnue, OT invisible', async () => { const GHOST = '00000000-0000-4000-8000-000000000000'; await http() .post('/work-orders') .set(auth(salma)) .send({ title: 'X', type: 'CORRECTIVE', assetId: GHOST }) .expect(400); await http() .post('/requests') .set(auth(karim)) .send({ assetId: GHOST, description: 'X' }) .expect(400); const { body: wos } = await http().get('/work-orders').set(auth(salma)); const un = wos.workOrders[0]; await http() .put(`/work-orders/${un.id}/assignees`) .set(auth(salma)) .send({ assigneeIds: [GHOST] }) .expect(400); await http() .patch(`/work-orders/${un.id}/checklist/${GHOST}`) .set(auth(salma)) .send({ state: 'DONE' }) .expect(404); await http().get(`/work-orders/${GHOST}`).set(auth(salma)).expect(404); await http() .post(`/requests/${GHOST}/reject`) .set(auth(salma)) .send({ reason: 'motif' }) .expect(404); }); it('référentiels du bilan : ajout (admin), doublon 409, renommage, 404', async () => { const { body } = await http().get('/auth/demo-accounts'); const adminCompte = body.accounts.find( (a: { roleName: string }) => a.roleName === 'Administrateur', ); const admin = ( await http().post('/auth/demo-login').send({ userId: adminCompte.id }) ).body.accessToken as string; const creee = await http() .post('/reference-values') .set(auth(admin)) .send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` }) .expect(201); await http() .post('/reference-values') .set(auth(admin)) .send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` }) .expect(409); await http() .patch(`/reference-values/${creee.body.id}`) .set(auth(admin)) .send({ isActive: false }) .expect(200); await http() .patch(`/reference-values/00000000-0000-4000-8000-000000000000`) .set(auth(admin)) .send({ label: 'X' }) .expect(404); // Salma (Dispatcher, sans SETTINGS.create) → 403 await http() .post('/reference-values') .set(auth(salma)) .send({ field: 'ANOMALY', label: 'Interdit' }) .expect(403); await prisma.referenceValue.delete({ where: { id: creee.body.id } }); }); });