# Guide Backend — Inventory

Guide complet du backend Symfony pour comprendre comment tout fonctionne, même si tu débutes.

## Table des matières

1. [Vue d'ensemble](#vue-densemble)
2. [Comment fonctionne une API REST](#comment-fonctionne-une-api-rest)
3. [Symfony + API Platform — les bases](#symfony--api-platform--les-bases)
4. [Les Entités (les modèles de données)](#les-entités)
5. [Les Controllers (les endpoints personnalisés)](#les-controllers)
6. [Le système d'audit](#le-système-daudit)
7. [L'authentification par session](#lauthentification-par-session)
8. [Les services](#les-services)
9. [Les migrations de base de données](#les-migrations)
10. [Les tests](#les-tests)
11. [Flux complet d'une requête](#flux-complet-dune-requête)
12. [Commandes Symfony utiles](#commandes-symfony-utiles)

---

## Vue d'ensemble

Le backend est une **API REST** construite avec :

- **Symfony 8** : le framework PHP (gère le routing, la sécurité, la config, etc.)
- **API Platform 4.2** : une surcouche qui génère automatiquement les endpoints CRUD à partir des entités
- **Doctrine ORM** : fait le lien entre les objets PHP et les tables PostgreSQL
- **PostgreSQL 16** : la base de données relationnelle

### Le principe

Au lieu d'écrire manuellement chaque endpoint (GET /machines, POST /machines, etc.), **API Platform** les génère automatiquement à partir des entités PHP. Tu déclares tes champs, tes relations, tes règles de sécurité directement sur la classe PHP, et API Platform fait le reste.

---

## Comment fonctionne une API REST

### C'est quoi une API REST ?

Une API REST c'est un serveur qui répond à des requêtes HTTP (comme un site web, mais au lieu de renvoyer du HTML, il renvoie du JSON).

### Les verbes HTTP

| Verbe | Action | Exemple |
|-------|--------|---------|
| `GET` | Lire des données | `GET /api/machines` → liste toutes les machines |
| `POST` | Créer une donnée | `POST /api/machines` + body JSON → crée une machine |
| `PUT` | Remplacer une donnée | `PUT /api/machines/123` + body JSON → remplace la machine 123 |
| `PATCH` | Modifier partiellement | `PATCH /api/machines/123` + body JSON → modifie certains champs |
| `DELETE` | Supprimer | `DELETE /api/machines/123` → supprime la machine 123 |

### Les codes de réponse HTTP

| Code | Signification | Quand |
|------|---------------|-------|
| `200` | OK | Requête réussie |
| `201` | Created | Ressource créée avec succès (POST) |
| `204` | No Content | Suppression réussie (DELETE) |
| `400` | Bad Request | Données invalides envoyées |
| `401` | Unauthorized | Pas connecté / session expirée |
| `403` | Forbidden | Connecté mais pas les permissions |
| `404` | Not Found | La ressource n'existe pas |
| `409` | Conflict | Doublon (ex: nom déjà pris) |
| `500` | Server Error | Bug côté serveur |

### Le format JSON-LD

L'API utilise **JSON-LD** (JSON Linked Data), une extension de JSON qui ajoute des métadonnées :

```json
{
  "@context": "/api/contexts/Machine",
  "@id": "/api/machines/cl1a2b3c4d5e6f7g8h9i0j1k",
  "@type": "Machine",
  "id": "cl1a2b3c4d5e6f7g8h9i0j1k",
  "name": "CNC Mazak 01",
  "reference": "CNM-001",
  "prix": "50000.00",
  "site": "/api/sites/cl9z8y7x6w5v4u3t2s1r0q",
  "createdAt": "2026-01-15T10:30:00+00:00"
}
```

Points importants :
- `@id` est l'**IRI** (Internationalized Resource Identifier) : c'est l'identifiant unique de la ressource dans l'API
- Les relations utilisent des IRIs : `"site": "/api/sites/cl9z8..."` au lieu d'un simple ID
- Les collections retournent un format hydra avec pagination :

```json
{
  "@context": "/api/contexts/Machine",
  "@id": "/api/machines",
  "@type": "hydra:Collection",
  "hydra:totalItems": 42,
  "hydra:member": [
    { "@id": "/api/machines/cl...", "name": "CNC 01", ... },
    { "@id": "/api/machines/cl...", "name": "Tour 02", ... }
  ]
}
```

---

## Symfony + API Platform — les bases

### La structure des fichiers

```
src/
├── Entity/          # Les classes PHP qui représentent les tables de la BDD
├── Controller/      # Les endpoints HTTP personnalisés (quand API Platform ne suffit pas)
├── EventSubscriber/ # Du code qui s'exécute automatiquement quand quelque chose se passe
├── Repository/      # Les requêtes SQL personnalisées
├── Service/         # La logique métier réutilisable
├── State/           # Les processeurs API Platform (interceptent le flux CRUD)
├── Security/        # L'authentification
├── Serializer/      # Personnalisation de la conversion entité ↔ JSON
├── Command/         # Commandes CLI (php bin/console app:xxx)
├── Enum/            # Les énumérations PHP (ex: catégories)
└── OpenApi/         # Personnalisation de la doc Swagger
```

### Comment Symfony traite une requête

```
Requête HTTP
    ↓
Symfony Router (quel code doit répondre ?)
    ↓
Sécurité (l'utilisateur a-t-il le droit ?)
    ↓
Controller ou API Platform (traitement)
    ↓
Doctrine ORM (lecture/écriture en BDD)
    ↓
Serializer (conversion entité → JSON)
    ↓
Réponse HTTP (JSON envoyé au frontend)
```

### Les attributs PHP 8

Le projet utilise les **attributs PHP 8** (les `#[...]`) au lieu des annotations (les `@...`). C'est la syntaxe moderne de PHP :

```php
// Attribut PHP 8 (ce qu'on utilise) ✅
#[ORM\Column(type: 'string', length: 255)]
private string $name;

// Annotation (ancien style, on ne l'utilise pas) ❌
/** @ORM\Column(type="string", length=255) */
```

---

## Les Entités

Les entités sont les classes PHP qui représentent les tables de la base de données. Chaque propriété de la classe correspond à une colonne.

### Anatomie d'une entité

Prenons un exemple simplifié :

```php
<?php
// src/Entity/Machine.php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ORM\Entity(repositoryClass: MachineRepository::class)]    // ← Lié à une table en BDD
#[ORM\HasLifecycleCallbacks]                                  // ← Active les hooks PrePersist/PreUpdate
#[ApiResource(                                                // ← Génère les endpoints API
    operations: [
        new GetCollection(security: "is_granted('ROLE_VIEWER')"),     // GET /api/machines
        new Get(security: "is_granted('ROLE_VIEWER')"),               // GET /api/machines/{id}
        new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),        // POST /api/machines
        new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),       // PATCH /api/machines/{id}
        new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),      // DELETE /api/machines/{id}
    ],
    normalizationContext: ['groups' => ['machine:read']],     // ← Quels champs exposer en lecture
    denormalizationContext: ['groups' => ['machine:write']],  // ← Quels champs accepter en écriture
    paginationItemsPerPage: 30,                               // ← 30 résultats par page
)]
class Machine
{
    #[ORM\Id]
    #[ORM\Column(type: 'string', length: 36)]
    #[Groups(['machine:read'])]                               // ← Exposé en lecture uniquement
    private string $id;

    #[ORM\Column(type: 'string', length: 255, unique: true)]
    #[Groups(['machine:read', 'machine:write'])]              // ← Exposé en lecture ET écriture
    private string $name;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
    #[Groups(['machine:read', 'machine:write'])]
    private ?string $prix = null;

    #[ORM\ManyToOne(targetEntity: Site::class)]               // ← Relation : chaque machine appartient à un site
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]   // ← Obligatoire, supprimé en cascade
    #[Groups(['machine:read', 'machine:write'])]
    private Site $site;

    #[ORM\Column(type: 'datetime_immutable')]
    #[Groups(['machine:read'])]                               // ← Lecture seule (pas dans machine:write)
    private \DateTimeImmutable $createdAt;

    // ... getters et setters
}
```

### Décryptage des attributs importants

| Attribut | Signification |
|----------|--------------|
| `#[ORM\Entity]` | Cette classe est stockée en BDD |
| `#[ORM\Column]` | Cette propriété est une colonne |
| `#[ORM\Id]` | C'est la clé primaire |
| `#[ORM\ManyToOne]` | Relation N→1 (plusieurs machines → un site) |
| `#[ORM\OneToMany]` | Relation 1→N (un site → plusieurs machines) |
| `#[ORM\ManyToMany]` | Relation N→N (machines ↔ constructeurs) |
| `#[ApiResource]` | API Platform génère les endpoints CRUD |
| `#[Groups]` | Contrôle quels champs sont visibles/modifiables |
| `security: "is_granted('ROLE_X')"` | Qui a le droit d'utiliser cet endpoint |

### Le trait CuidEntityTrait

Toutes les entités utilisent un trait partagé qui génère les IDs et gère les timestamps :

```php
// src/Entity/Trait/CuidEntityTrait.php
trait CuidEntityTrait
{
    #[ORM\PrePersist]    // ← S'exécute automatiquement AVANT l'insertion en BDD
    public function generateId(): void
    {
        if (!isset($this->id)) {
            $this->id = 'cl' . bin2hex(random_bytes(12));    // ← Génère un ID unique de 26 chars
        }
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }

    #[ORM\PreUpdate]     // ← S'exécute automatiquement AVANT une mise à jour
    public function updateTimestamp(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }
}
```

### Les entités du projet

#### Entités "catalogue" (les éléments qu'on gère)

| Entité | Table | Champs clés | Relations |
|--------|-------|-------------|-----------|
| **Machine** | `machine` | name, reference, prix | → Site, ↔ Constructeur, → Documents |
| **Composant** | `composant` | name, reference, description, prix, structure (JSON) | → ModelType, → Product, ↔ Constructeur |
| **Piece** | `piece` | name, reference, description, prix, productIds (JSON) | → ModelType, → Product, ↔ Constructeur |
| **Product** | `product` | name, reference, supplierPrice | → ModelType, ↔ Constructeur |

#### Entités de classification

| Entité | Table | Champs clés | Rôle |
|--------|-------|-------------|------|
| **Site** | `site` | name, contactName, contactPhone, contactAddress | Regrouper les machines par lieu |
| **Constructeur** | `constructeur` | name, email, phone | Fournisseurs/fabricants partagés |
| **ModelType** | `model_type` | name, code, category (enum), skeletons (JSON) | Catégoriser composants/pièces/produits |

#### Entités de liaison hiérarchique (structure machine)

| Entité | Rôle | Relations |
|--------|------|-----------|
| **MachineComponentLink** | Lie un composant à une machine | → Machine, → Composant, → parent (self) |
| **MachinePieceLink** | Lie une pièce à une machine | → Machine, → Piece, → parent composant |
| **MachineProductLink** | Lie un produit à une machine | → Machine, → Product, → parent (flexible) |

Ces entités permettent la **structure arborescente** : un composant peut contenir des pièces, qui contiennent des produits.

#### Entités de métadonnées

| Entité | Rôle |
|--------|------|
| **CustomField** | Définition d'un champ personnalisé (nom, type, options) |
| **CustomFieldValue** | Valeur d'un champ personnalisé pour une entité donnée |
| **Document** | Fichier uploadé (PDF, image) rattaché à une entité |
| **AuditLog** | Entrée du journal d'audit (diff + snapshot) |
| **Comment** | Commentaire/ticket sur une fiche |
| **Profile** | Compte utilisateur (email, rôle, mot de passe hashé) |

### Les relations entre entités (schéma simplifié)

```
Site ──1:N──► Machine ──1:N──► MachineComponentLink ──► Composant
                │                      │
                │                      └──1:N──► MachinePieceLink ──► Piece
                │                                      │
                │                                      └──1:N──► MachineProductLink ──► Product
                │
                └──N:N──► Constructeur (via table de jointure)

ModelType ──1:N──► Composant / Piece / Product
                      │
                      └──► CustomField ──1:N──► CustomFieldValue

Machine / Composant / Piece / Product ──1:N──► Document
                                        ──1:N──► CustomFieldValue
```

---

## Les Controllers

API Platform génère automatiquement les endpoints CRUD standard. Les controllers personnalisés gèrent les cas plus complexes.

### Liste des controllers

#### Authentification (3 controllers)

**SessionProfileController** (`/api/session/profile`) — Login/Logout

```
POST   /api/session/profile      → Se connecter (payload: { profileId, password })
GET    /api/session/profile      → Récupérer le profil connecté
DELETE /api/session/profile      → Se déconnecter
```

**SessionProfilesController** (`/api/session/profiles`) — Liste des profils

```
GET    /api/session/profiles     → Liste tous les profils actifs (page de login)
```

**AdminProfileController** (`/api/admin/profiles`) — Administration des utilisateurs

```
GET    /api/admin/profiles                    → Liste tous les profils (ADMIN only)
POST   /api/admin/profiles                    → Créer un profil
PUT    /api/admin/profiles/{id}/role          → Changer le rôle d'un profil
PUT    /api/admin/profiles/{id}/password      → Réinitialiser un mot de passe
PUT    /api/admin/profiles/{id}/deactivate    → Désactiver un profil
```

#### Données et logique métier

**MachineStructureController** — Structure hiérarchique des machines

```
GET    /api/machines/{id}/structure           → Récupérer l'arborescence complète
PATCH  /api/machines/{id}/structure           → Modifier l'arborescence
POST   /api/machines/{id}/clone               → Cloner une machine avec toute sa structure
```

**MachineCustomFieldsController** — Champs personnalisés machines

```
POST   /api/machines/{id}/custom-fields/init  → Initialiser les champs personnalisés manquants
```

**EntityHistoryController** — Historique d'audit par entité

```
GET    /api/{entityType}/{id}/history         → 200 derniers événements d'audit
```

**ActivityLogController** — Journal d'activité global

```
GET    /api/activity-log                      → Liste paginée avec filtres (entityType, action)
```

**CommentController** — Commentaires/tickets

```
POST   /api/comments                          → Créer un commentaire
PATCH  /api/comments/{id}/resolve             → Résoudre un commentaire
GET    /api/comments/unresolved-count         → Nombre de commentaires non résolus
```

**CustomFieldValueController** — Valeurs de champs personnalisés

```
POST   /api/custom-field-values               → Créer/mettre à jour une valeur (upsert)
DELETE /api/custom-field-values/{id}           → Supprimer une valeur
```

#### Fichiers

**DocumentQueryController** — Requêter les documents par entité

```
GET    /api/documents/by-site/{id}            → Documents d'un site
GET    /api/documents/by-machine/{id}         → Documents d'une machine
GET    /api/documents/by-composant/{id}       → Documents d'un composant
GET    /api/documents/by-piece/{id}           → Documents d'une pièce
GET    /api/documents/by-product/{id}         → Documents d'un produit
```

**DocumentServeController** — Servir les fichiers

```
GET    /api/documents/{id}/file               → Afficher le fichier (inline)
GET    /api/documents/{id}/download           → Télécharger le fichier (attachment)
```

#### Monitoring

**HealthCheckController** — Vérification de santé

```
GET    /api/health                            → Version, latence BDD, mémoire, version PHP
```

### Exemple de controller commenté

```php
<?php
// src/Controller/CommentController.php

namespace App\Controller;

use App\Entity\Comment;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

class CommentController extends AbstractController
{
    #[Route('/api/comments', methods: ['POST'])]    // ← Définit l'URL et le verbe HTTP
    public function create(
        Request $request,                            // ← La requête HTTP entrante
        EntityManagerInterface $em,                  // ← Pour écrire en BDD (injecté automatiquement)
    ): JsonResponse {
        $this->denyAccessUnlessGranted('ROLE_VIEWER');  // ← Vérifie que l'utilisateur est connecté

        $data = json_decode($request->getContent(), true);  // ← Parse le body JSON

        $comment = new Comment();
        $comment->setContent($data['content']);
        $comment->setEntityType($data['entityType']);
        $comment->setEntityId($data['entityId']);
        // ... autres champs

        $em->persist($comment);   // ← Dit à Doctrine "je veux sauvegarder ça"
        $em->flush();             // ← Exécute réellement le INSERT SQL

        return $this->json($comment, 201);  // ← Renvoie le commentaire créé avec le code 201
    }
}
```

---

## Le système d'audit

Chaque modification sur les entités principales est automatiquement enregistrée dans un journal d'audit. C'est un des points forts de l'application.

### Comment ça marche ?

Les **Event Subscribers** de Doctrine interceptent les opérations de base de données **avant** qu'elles soient exécutées (événement `onFlush`).

```
L'utilisateur modifie une machine
    ↓
Doctrine détecte le changement
    ↓
onFlush se déclenche
    ↓
Le subscriber calcule le diff (ancien → nouveau)
    ↓
Le subscriber crée un AuditLog avec :
  - entityType : "machine"
  - entityId : "cl1a2b3c..."
  - action : "update"
  - diff : { "name": { "from": "CNC 01", "to": "CNC 02" } }
  - snapshot : { état complet de la machine }
  - actorProfileId : "cl9z8y7x..." (qui a fait la modif)
    ↓
Les deux (machine + audit log) sont sauvegardés en même temps
```

### Le diff

Le diff capture exactement ce qui a changé :

```json
{
  "name": { "from": "CNC Mazak 01", "to": "CNC Mazak 02" },
  "prix": { "from": "45000.00", "to": "50000.00" },
  "constructeurIds": {
    "from": ["cl111...", "cl222..."],
    "to": ["cl111...", "cl333..."]
  }
}
```

### Le snapshot

Le snapshot capture l'état complet de l'entité au moment de la modification :

```json
{
  "id": "cl1a2b3c...",
  "name": "CNC Mazak 02",
  "reference": "CNM-001",
  "prix": "50000.00",
  "siteId": "cl9z8y7x...",
  "constructeurIds": ["cl111...", "cl333..."]
}
```

### Les subscribers d'audit

| Subscriber | Entité | Type |
|------------|--------|------|
| MachineAuditSubscriber | Machine | Complex (avec constructeurs + custom fields) |
| ComposantAuditSubscriber | Composant | Complex |
| PieceAuditSubscriber | Piece | Complex |
| ProductAuditSubscriber | Product | Complex |
| ConstructeurAuditSubscriber | Constructeur | Simple |
| DocumentAuditSubscriber | Document | Simple |
| ModelTypeAuditSubscriber | ModelType | Simple |

**Simple** = suit seulement les champs de l'entité
**Complex** = suit aussi les relations ManyToMany (constructeurs) et les champs personnalisés

### AbstractAuditSubscriber

La classe de base qui contient toute la logique partagée :

```php
abstract class AbstractAuditSubscriber implements EventSubscriber
{
    // Méthode à implémenter par chaque subscriber
    abstract protected function getEntityClass(): string;       // Ex: Machine::class
    abstract protected function getEntityType(): string;        // Ex: 'machine'
    abstract protected function buildSnapshot($entity): array;  // Construit le snapshot

    // Deux chemins d'exécution :
    // 1. onFlushSimple() : pour les entités sans collections ManyToMany
    // 2. onFlushComplex() : pour les entités avec constructeurs (détecte les ajouts/suppressions)
}
```

### Autres subscribers

| Subscriber | Rôle |
|------------|------|
| **PieceProductSyncSubscriber** | Synchronise le champ `productIds` sur Piece quand un Product est lié/délié |
| **UniqueConstraintSubscriber** | Capture les erreurs de doublon PostgreSQL et renvoie un message clair |

---

## L'authentification par session

### Le flux complet

```
1. GET /api/session/profiles
   → Retourne la liste des profils actifs (nom, prénom, email, hasPassword)
   → Le frontend affiche la page de login avec les profils disponibles

2. POST /api/session/profile
   Body: { "profileId": "cl...", "password": "secret" }
   → Le backend vérifie le mot de passe
   → Si OK : stocke profileId dans la session PHP, retourne le profil
   → Si KO : retourne 401

3. GET /api/session/profile (à chaque chargement de page)
   → Le navigateur envoie le cookie de session automatiquement
   → Le backend retrouve le profil via la session
   → Retourne le profil connecté ou 401

4. DELETE /api/session/profile
   → Supprime le profileId de la session
   → L'utilisateur est déconnecté
```

### La sécurité sur les endpoints

Chaque endpoint API Platform a une règle de sécurité :

```php
new GetCollection(security: "is_granted('ROLE_VIEWER')")   // Lecture → minimum ROLE_VIEWER
new Post(security: "is_granted('ROLE_GESTIONNAIRE')")       // Création → minimum ROLE_GESTIONNAIRE
```

Les controllers personnalisés utilisent :
```php
$this->denyAccessUnlessGranted('ROLE_VIEWER');
```

### La hiérarchie des rôles

Grâce à la hiérarchie, un ADMIN a automatiquement tous les rôles inférieurs :

```
ROLE_ADMIN ─── a aussi ──► ROLE_GESTIONNAIRE ──► ROLE_VIEWER ──► ROLE_USER
```

Donc `is_granted('ROLE_VIEWER')` accepte aussi les GESTIONNAIRES et les ADMINS.

---

## Les services

### DocumentStorageService

Gère le stockage des fichiers sur le système de fichiers :

```php
// Stocker un fichier uploadé
$path = $storageService->store($uploadedFile, $entityType, $entityId);

// Supprimer un fichier
$storageService->delete($path);
```

Les fichiers sont stockés dans `var/documents/{entityType}/{entityId}/{filename}`.

### PdfCompressorService

Compresse les fichiers PDF via Ghostscript pour réduire leur taille :

```php
$compressorService->compress($filePath);
```

### ModelTypeCategoryConversionService

Permet de convertir la catégorie d'un ModelType (ex: transformer un type "composant" en type "pièce").

---

## Les migrations

Les migrations sont des scripts SQL qui modifient la structure de la base de données. Elles sont dans le dossier `migrations/`.

### Principe

Quand tu ajoutes un champ à une entité, il faut créer une migration pour mettre à jour la BDD :

```bash
# Générer une migration à partir des changements détectés
make shell
php bin/console doctrine:migrations:diff

# Appliquer les migrations
php bin/console doctrine:migrations:migrate
```

### Particularités PostgreSQL

Les migrations utilisent du **SQL brut** avec des gardes pour l'idempotence :

```sql
-- On peut relancer la migration sans erreur
ALTER TABLE machine ADD COLUMN IF NOT EXISTS description TEXT;
DROP INDEX IF EXISTS idx_machine_name;
CREATE UNIQUE INDEX IF NOT EXISTS idx_machine_name ON machine (name);
```

**Attention aux noms de colonnes** : PostgreSQL stocke tout en **minuscules**. Donc `typePieceId` en PHP devient `typepieceid` en SQL. Toujours utiliser des noms lowercase dans le SQL brut.

---

## Les tests

### Stack de test

- **PHPUnit 12** : framework de test PHP
- **API Platform Test** : utilitaires pour tester des endpoints API
- **DAMA DoctrineTestBundle** : wrappe chaque test dans une transaction avec rollback automatique (pas besoin de nettoyer la BDD entre les tests)

### Structure

```
tests/
├── AbstractApiTestCase.php    # Classe de base avec helpers
└── Api/
    └── Entity/
        ├── MachineTest.php    # Tests des endpoints machine
        ├── SiteTest.php       # Tests des endpoints site
        └── ...
```

### Exemple de test

```php
class MachineTest extends AbstractApiTestCase
{
    public function testCreateMachine(): void
    {
        // Créer un client HTTP connecté en tant que gestionnaire
        $client = $this->createGestionnaireClient();

        // Créer un site (prérequis)
        $site = $this->createSite();

        // Envoyer une requête POST pour créer une machine
        $client->request('POST', '/api/machines', [
            'json' => [
                'name' => 'Machine Test',
                'reference' => 'MT-001',
                'site' => '/api/sites/' . $site->getId(),
            ],
        ]);

        // Vérifier que la réponse est 201 Created
        $this->assertResponseStatusCodeSame(201);

        // Vérifier le contenu de la réponse
        $this->assertJsonContains([
            'name' => 'Machine Test',
            'reference' => 'MT-001',
        ]);
    }
}
```

### Helpers disponibles dans AbstractApiTestCase

| Méthode | Description |
|---------|-------------|
| `createViewerClient()` | Client HTTP connecté avec ROLE_VIEWER |
| `createGestionnaireClient()` | Client HTTP connecté avec ROLE_GESTIONNAIRE |
| `createAdminClient()` | Client HTTP connecté avec ROLE_ADMIN |
| `createProfile()` | Crée un profil utilisateur en BDD |
| `createSite()` | Crée un site en BDD |
| `createMachine()` | Crée une machine en BDD |

### Lancer les tests

```bash
make test                                              # Tous les tests
make test FILES=tests/Api/Entity/MachineTest.php       # Un fichier
make test-setup                                        # (Re)créer la BDD de test
```

---

## Flux complet d'une requête

### Exemple : créer une machine

```
1. Le frontend envoie :
   POST /api/machines
   Content-Type: application/ld+json
   Cookie: PHPSESSID=abc123
   {
     "name": "CNC Mazak 01",
     "reference": "CNM-001",
     "prix": "50000.00",
     "site": "/api/sites/cl9z8y7x..."
   }

2. Symfony reçoit la requête
   → Le routeur identifie : c'est un endpoint API Platform (POST sur Machine)

3. Sécurité
   → Vérifie le cookie de session → retrouve le profil connecté
   → Vérifie is_granted('ROLE_GESTIONNAIRE') → OK

4. Désérialisation (JSON → objet PHP)
   → API Platform convertit le JSON en objet Machine
   → Le champ "site" (IRI) est résolu en objet Site
   → Seuls les champs du groupe 'machine:write' sont acceptés

5. Validation
   → Vérifie les contraintes (name non vide, site existe, etc.)

6. Persistence (objet PHP → BDD)
   → Doctrine déclenche PrePersist (CuidEntityTrait)
     → Génère l'ID : "cl" + 24 hex chars aléatoires
     → Set createdAt et updatedAt
   → Doctrine détecte l'INSERT à faire

7. Audit (onFlush)
   → MachineAuditSubscriber détecte la nouvelle machine
   → Crée un AuditLog avec action='create', diff, snapshot
   → L'AuditLog est aussi ajouté à la transaction

8. Flush
   → Doctrine exécute les requêtes SQL :
     INSERT INTO machine (id, name, reference, ...) VALUES (...)
     INSERT INTO audit_log (id, entity_type, entity_id, action, diff, snapshot, ...) VALUES (...)

9. Sérialisation (objet PHP → JSON)
   → API Platform convertit la Machine en JSON-LD
   → Seuls les champs du groupe 'machine:read' sont inclus

10. Réponse
    HTTP/1.1 201 Created
    {
      "@context": "/api/contexts/Machine",
      "@id": "/api/machines/cl1a2b3c...",
      "@type": "Machine",
      "id": "cl1a2b3c...",
      "name": "CNC Mazak 01",
      ...
    }
```

---

## Commandes Symfony utiles

Lancer ces commandes dans le conteneur Docker (`make shell` pour y entrer) :

| Commande | Description |
|----------|-------------|
| `php bin/console debug:router` | Voir toutes les routes disponibles |
| `php bin/console debug:config api_platform` | Voir la config API Platform |
| `php bin/console doctrine:schema:validate` | Vérifier que les entités sont synchronisées avec la BDD |
| `php bin/console doctrine:migrations:diff` | Générer une migration à partir des changements |
| `php bin/console doctrine:migrations:migrate` | Appliquer les migrations |
| `php bin/console cache:clear` | Vider le cache (résout beaucoup de problèmes) |
| `php bin/console app:compress-pdf` | Compresser les PDFs existants |
| `php bin/console app:create-profile` | Créer un profil utilisateur |

---

## Résumé des points clés pour un débutant

1. **API Platform génère les endpoints CRUD automatiquement** à partir des entités — tu n'as pas besoin d'écrire de controllers pour les opérations standard
2. **Les attributs PHP 8** (`#[...]`) remplacent les annotations et configurent tout : BDD, API, sérialisation, sécurité
3. **Les groupes de sérialisation** (`machine:read`, `machine:write`) contrôlent quels champs sont visibles/modifiables
4. **L'audit est automatique** : chaque modification est tracée sans rien avoir à faire manuellement
5. **L'authentification est par session (cookies)**, pas par tokens JWT
6. **Les IDs sont des CUID** (chaînes aléatoires), pas des auto-increment
7. **PostgreSQL stocke les noms en minuscules** : attention dans le SQL brut
8. **Les tests utilisent des transactions** : chaque test est isolé et la BDD est nettoyée automatiquement
