mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
Serveur : updatedAt exposé au détail OT ; baseUpdatedAt optionnel sur transition/coche/bilan → 409 « Conflit de version » contextualisé (qui, quand) ; toute écriture secondaire (coche, bilan, commentaire, conso, MO) fait avancer la version — sans version fournie, le web est inchangé. ADR-003 : sécurité & protocole de routage mobile (qui vit où sur l'appareil, purge complète à la déconnexion — correctif réel : la file et le cache persisté survivaient au logout). Mobile : file persistée AsyncStorage rejouée dans l'ordre — succès → propagation de la version fraîche aux saisies restantes du même OT (nos écritures ne se conflictent pas entre elles, un écart étranger reste détecté) ; coupure → tout attend ; refus → CONFLIT, la file s'arrête, l'humain tranche (voir l'OT / rejouer sur version à jour / abandonner). Transitions, coches, bilan et photos (D5, compressées ~1600 px) passent par la file avec patch optimiste du cache ; écran Synchro (badge tabbar ambre/rouge) ; préchargement parc + référentiels (le bilan hors-ligne a ses vocabulaires). Recette « mode avion » 13/13 en Expo web piloté : gestes hors-ligne → 3 en file → modification concurrente de Salma → conflit tranché → serveur Terminé avec bilan. 17 tests jest-expo, 74 tests API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|