mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
185
apps/api/test/referentiel.e2e-spec.ts
Normal 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 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user