mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Trouvé en recette : GET /assets/options n'avait aucun filtre — tout Demandeur voyait le parc complet dans le sélecteur d'équipement de "Nouvelle demande", sur le web ET le mobile (même endpoint partagé). Risque réel : signaler accidentellement une panne sur un ascenseur qu'on ne gère pas. Corrigé à la racine, sur les deux plateformes à la fois : - Relation many-to-many User↔Location (assignedSites/assignedUsers, migration r6_demandeur_sites, même style que Team.members) — vide = aucune restriction, comportement historique inchangé pour tous les rôles sauf un Demandeur affecté à un site. - AssetsService.allowedLocationIds(user) : sites + zones filles autorisés, ou null si aucune restriction — réutilisée par options() ET par RequestsService.create (défense en profondeur : un assetId soumis directement hors périmètre est rejeté, 400). - UsersService : assertTopLevelSites (un Demandeur est affecté à un site, jamais une zone) ; invite()/update() branchent locationIds (remplace l'affectation, comme teamIds). - Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée. - Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en raccourci — résout uniquement contre les options déjà chargées (déjà filtrées), jamais de repli sur le parc complet qui annulerait la restriction. Aucun changement à useAssetOptions() : le filtrage serveur profite automatiquement au formulaire mobile. - Seed : Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit : create() comparait allowed.includes(dto.assetId), mais allowed est une liste d'ids de sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout signalement d'un Demandeur affecté, y compris dans son propre périmètre. Corrigé (comparaison sur asset.locationId) ; méthode renommée allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle retourne. exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de Karim — sinon rejetés par la nouvelle règle, comportement voulu). 79/80 tests API verts, le seul échec (documents-analytics, monthCost) est le flake calendaire déjà identifié cette session, sans rapport. Typecheck/tests/lint verts sur les 4 paquets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
/**
|
|
* 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 — sur A2, dans son site rattaché (Tour
|
|
// Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre.
|
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2');
|
|
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);
|
|
expect(position).toBeGreaterThanOrEqual(0);
|
|
// 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
|
|
);
|
|
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()
|
|
.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 () => {
|
|
// B1 : dans le site rattaché de Karim (Tour Atlas, R6.6).
|
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
|
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1');
|
|
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 } });
|
|
});
|
|
});
|