/** * E2E R3 (recette) — recherche globale : chaque famille de résultats est * filtrée par la matrice, les OT respectent « voir autre » ; au passage, * rattachements Tiers (siteNames) et période analytics paramétrable. */ 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('Recherche globale & corrections de recette R3 (e2e)', () => { let app: INestApplication; let yasmine: string; // Administrateur let salma: string; // Dispatcher (voir autre) let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS let karim: string; // Demandeur : aucune des trois familles let otId: string; const prisma = new PrismaClient(); const http = () => request(app.getHttpServer()); const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); const suffix = `rech-${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; }; yasmine = await login('Administrateur'); salma = await login('Dispatcher'); youssef = await login('Technicien limité'); karim = await login('Demandeur'); // Un OT au titre unique, assigné à Ahmed (PAS à Youssef). const { body: assets } = await http().get('/assets').set(auth(salma)); const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien'); const cree = await http() .post('/work-orders') .set(auth(salma)) .send({ title: `Recherche E2E ${suffix}`, type: 'CORRECTIVE', priority: 'LOW', assetId: a1.id, assigneeIds: [ahmed.id], }) .expect(201); otId = cree.body.id; }); afterAll(async () => { await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } }); await app?.close(); await prisma.$disconnect(); }); it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => { const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200); expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId); const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200); expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true); expect(parRef.body.assets[0].siteName).toBeTruthy(); const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200); expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true); }); it('technicien limité : ne trouve pas l’OT d’un autre, ni les sites ; le parc oui', async () => { const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200); expect(res.body.workOrders).toHaveLength(0); // pas « voir autre » const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200); expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200); expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view }); it('demandeur : 200 mais aucune famille (aucune permission view)', async () => { const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200); expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] }); }); it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => { const res = await http().get('/search?q=a').set(auth(salma)).expect(200); expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] }); }); it('sans jeton : 401 (API fermée par défaut)', async () => { await http().get('/search?q=atlas').expect(401); }); it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => { const { body } = await http().get('/partners').set(auth(yasmine)).expect(200); const atlas = body.partners.find( (p: { name: string }) => p.name === 'Atlas Property Management', ); expect(atlas.siteNames).toEqual(['Tour Atlas']); const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc'); expect(fournisseur.siteNames).toEqual([]); }); it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => { const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200); expect(trois.body.months).toBe(3); expect(trois.body.costsByMonth).toHaveLength(3); const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200); expect(defaut.body.months).toBe(12); expect(defaut.body.costsByMonth).toHaveLength(12); const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200); expect(inconnu.body.months).toBe(12); }); it('sites : le rattachement n’accepte qu’un tiers Client / syndic', async () => { const { body: partners } = await http().get('/partners').set(auth(yasmine)); const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER'); const { body: locations } = await http().get('/locations').set(auth(yasmine)); const site = locations.locations.find( (l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place', ); await http() .patch(`/locations/${site.id}`) .set(auth(yasmine)) .send({ partnerId: fournisseur.id }) .expect(400); }); }); describe('Verrou optimiste D2 (e2e) — la version protège les saisies mobiles', () => { let app: INestApplication; let salma: string; let ahmed: string; let otId: string; const prisma = new PrismaClient(); const http = () => request(app.getHttpServer()); const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); const suffix = `verrou-${Date.now().toString(36)}`; beforeAll(async () => { 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; }; salma = await login('Dispatcher'); ahmed = await login('Technicien'); const { body: assets } = await http().get('/assets').set(auth(salma)); const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); const technicien = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien'); const cree = await http() .post('/work-orders') .set(auth(salma)) .send({ title: `Verrou E2E ${suffix}`, type: 'CORRECTIVE', assetId: a1.id, assigneeIds: [technicien.id], }) .expect(201); otId = cree.body.id; }); afterAll(async () => { await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } }); await app?.close(); await prisma.$disconnect(); }); it('version à jour : la transition passe ; le détail expose updatedAt', async () => { const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200); expect(detail.body.updatedAt).toBeTruthy(); await http() .post(`/work-orders/${otId}/transition`) .set(auth(ahmed)) .send({ to: 'IN_PROGRESS', baseUpdatedAt: detail.body.updatedAt }) .expect(200); }); it('OT modifié entre-temps (commentaire) : 409 « Conflit de version » contextualisé', async () => { const detail = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200); const versionLue = detail.body.updatedAt as string; // Salma commente pendant qu'Ahmed est « hors-ligne » — la version avance await http() .post(`/work-orders/${otId}/comments`) .set(auth(salma)) .send({ message: 'Réassigné après appel du syndic' }) .expect(201); const refus = await http() .post(`/work-orders/${otId}/transition`) .set(auth(ahmed)) .send({ to: 'ON_HOLD', baseUpdatedAt: versionLue }) .expect(409); expect(refus.body.message).toContain('Conflit de version'); expect(refus.body.message).toContain('Salma'); // L'OT n'a PAS bougé — rien d'écrasé en silence const apres = await http().get(`/work-orders/${otId}`).set(auth(ahmed)).expect(200); expect(apres.body.status).toBe('IN_PROGRESS'); }); it('le bilan et la coche font avancer la version (sinon le verrou est aveugle)', async () => { const avant = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt; const { body: refs } = await http().get('/reference-values').set(auth(ahmed)); const porte = refs.referenceValues.find( (v: { field: string; isActive: boolean }) => v.field === 'DOOR_STATE' && v.isActive, ); await http() .put(`/work-orders/${otId}/report`) .set(auth(ahmed)) .send({ doorStateId: porte.id }) .expect(200); const apres = (await http().get(`/work-orders/${otId}`).set(auth(ahmed))).body.updatedAt; expect(new Date(apres).getTime()).toBeGreaterThan(new Date(avant).getTime()); }); it('sans baseUpdatedAt (web) : comportement inchangé, même après modification', async () => { await http() .post(`/work-orders/${otId}/comments`) .set(auth(salma)) .send({ message: 'Nouvelle note' }) .expect(201); await http() .post(`/work-orders/${otId}/transition`) .set(auth(ahmed)) .send({ to: 'ON_HOLD' }) .expect(200); }); });