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:
128
apps/api/test/invitation.e2e-spec.ts
Normal file
128
apps/api/test/invitation.e2e-spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
|
||||
* aucun compte actif avant activation (décision maquettes R1).
|
||||
*/
|
||||
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('Invitation → activation (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let admin: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
|
||||
|
||||
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 compte = body.accounts.find(
|
||||
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||
);
|
||||
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('invite, empêche la connexion avant activation, active, connecte', async () => {
|
||||
const { body: roles } = await http()
|
||||
.get('/roles')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(200);
|
||||
const technicien = roles.roles.find(
|
||||
(r: { name: string }) => r.name === 'Technicien',
|
||||
);
|
||||
|
||||
// 1. Invitation → compte « invited », lien émis
|
||||
const invitation = await http()
|
||||
.post('/users/invitations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
|
||||
.expect(201);
|
||||
expect(invitation.body.activationToken).toBeTruthy();
|
||||
|
||||
const { body: users } = await http()
|
||||
.get('/users')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
const invited = users.users.find((u: { email: string }) => u.email === email);
|
||||
expect(invited.status).toBe('invited');
|
||||
|
||||
// 2. Pas de connexion possible avant activation
|
||||
await http()
|
||||
.post('/auth/login')
|
||||
.send({ email, password: 'MotDePasse!123' })
|
||||
.expect(401);
|
||||
|
||||
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
|
||||
const renvoi = await http()
|
||||
.post(`/users/${invitation.body.userId}/invitation`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(201);
|
||||
await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
|
||||
.expect(400);
|
||||
|
||||
// 4. Activation → connecté directement, statut « active »
|
||||
const activation = await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
|
||||
.expect(200);
|
||||
expect(activation.body.user.role.name).toBe('Technicien');
|
||||
await http()
|
||||
.get('/users/me')
|
||||
.set('Authorization', `Bearer ${activation.body.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
|
||||
await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post('/auth/login')
|
||||
.send({ email, password: 'MotDePasse!123' })
|
||||
.expect(200);
|
||||
|
||||
// 6. Renvoyer un lien sur un compte activé → refus
|
||||
await http()
|
||||
.post(`/users/${invitation.body.userId}/invitation`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
|
||||
const { body: roles } = await http()
|
||||
.get('/roles')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
await http()
|
||||
.post('/users/invitations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({
|
||||
email: 'dispatcher@demo.siop.ma',
|
||||
displayName: 'Doublon',
|
||||
roleId: roles.roles[0].id,
|
||||
})
|
||||
.expect(409);
|
||||
await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user