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>
This commit is contained in:
pr-daaif
2026-07-16 12:27:48 +01:00
parent 6d9aafdab4
commit 266ffaaf1b
36 changed files with 6016 additions and 13 deletions

View File

@@ -0,0 +1,185 @@
/**
* 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);
});
});