Files
siop2/apps/api/test/referentiel.e2e-spec.ts
pr-daaif 266ffaaf1b feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests
- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:27:48 +01:00

186 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
*/
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('Référentiel (e2e)', () => {
let app: INestApplication;
let admin: string; // jetons
let technicien: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = Date.now().toString(36); // données de test uniques et repérables
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;
};
admin = await login('Administrateur');
technicien = await login('Technicien');
});
afterAll(async () => {
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('parcours de recette : site → zone → appareil avec organes', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({
name: `Site Recette ${suffix}`,
city: 'Casablanca',
latitude: 33.59,
longitude: -7.61,
})
.expect(201);
expect(site.body.parentId).toBeNull();
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
.expect(201);
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const equipement = cats.categories.find(
(c: { kind: string }) => c.kind === 'EQUIPMENT',
);
const typeOrgane = cats.categories.find(
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
);
const asset = await http()
.post('/assets')
.set('Authorization', `Bearer ${admin}`)
.send({
reference: `T-${suffix}`,
brand: 'Otis',
model: 'Gen2',
categoryId: equipement.id,
locationId: zone.body.id,
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
})
.expect(201);
expect(asset.body.components).toHaveLength(1);
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
// Ajout puis retrait d'un organe
const organe = await http()
.post(`/assets/${asset.body.id}/components`)
.set('Authorization', `Bearer ${admin}`)
.send({ typeId: typeOrgane.id })
.expect(201);
await http()
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
.set('Authorization', `Bearer ${admin}`)
.expect(204);
});
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Site Profond ${suffix}` })
.expect(201);
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
.expect(201);
await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
.expect(400);
});
it('refuse un appareil dont la catégorie nest pas un équipement', async () => {
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const typeOrgane = cats.categories.find((c: { kind: string }) => c.kind === 'COMPONENT_TYPE');
const { body: locs } = await http()
.get('/locations')
.set('Authorization', `Bearer ${admin}`);
await http()
.post('/assets')
.set('Authorization', `Bearer ${admin}`)
.send({
reference: `KO-${suffix}`,
brand: 'Test',
categoryId: typeOrgane.id, // kind COMPONENT_TYPE → refus
locationId: locs.locations[0].id,
})
.expect(400);
});
it('la matrice vit : un Technicien lit le parc mais ne crée rien', async () => {
await http().get('/assets').set('Authorization', `Bearer ${technicien}`).expect(200);
await http().get('/locations').set('Authorization', `Bearer ${technicien}`).expect(200);
await http()
.post('/locations')
.set('Authorization', `Bearer ${technicien}`)
.send({ name: `Interdit ${suffix}` })
.expect(403);
await http().get('/users').set('Authorization', `Bearer ${technicien}`).expect(403);
});
it('le seed de la maquette est en place (A1 et ses organes, statuts)', async () => {
const { body } = await http()
.get('/assets')
.set('Authorization', `Bearer ${admin}`)
.expect(200);
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
expect(a1.siteName).toBe('Résidence Al Manar');
expect(a1.componentCount).toBe(4);
const b2 = body.assets.find((a: { reference: string }) => a.reference === 'B2');
expect(b2.status).toBe('OUT_OF_SERVICE');
});
it('catégorie utilisée : désactivable, jamais supprimable (la route nexiste pas)', async () => {
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const used = cats.categories.find((c: { usageCount: number }) => c.usageCount > 0);
await http()
.delete(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.expect(404); // pas de DELETE dans le contrat
const off = await http()
.patch(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.send({ isActive: false })
.expect(200);
expect(off.body.isActive).toBe(false);
await http()
.patch(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.send({ isActive: true })
.expect(200);
});
});