mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
- 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>
186 lines
6.6 KiB
TypeScript
186 lines
6.6 KiB
TypeScript
/**
|
||
* 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 n’est 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 n’existe 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);
|
||
});
|
||
});
|