# Plan Qualité — Tests · Refactoring · Performance
> Codebase: **Unified** — Laravel 11 · ~59K LOC PHP · 65 modèles · 901 vues Blade
> Date: 2026-05-05 · Priorités: P1 (critique) → P3 (nice-to-have)

---

## Table des matières
1. [Diagnostic initial](#1-diagnostic-initial)
2. [Tests](#2-tests)
3. [Refactoring](#3-refactoring)
4. [Performance](#4-performance)
5. [CI/CD](#5-cicd)
6. [Roadmap consolidée](#6-roadmap-consolidée)

---

## 1. Diagnostic initial

Avant toute action, mesurer la situation actuelle pour avoir une baseline objective.

### 1.1 Couverture de tests
```bash
php artisan test --coverage --min=0
```
→ Objectif : connaître le % de couverture de ligne et de branche par namespace.

### 1.2 Analyse statique
```bash
# Installer si absent
composer require --dev larastan/larastan
./vendor/bin/phpstan analyse app --level=5 > phpstan-baseline.txt
```

### 1.3 Détection des N+1
```bash
composer require --dev barryvdh/laravel-debugbar
# Activer DEBUGBAR_ENABLED=true, parcourir les pages clés, noter les N+1
```

### 1.4 Profiling des requêtes lentes
```sql
-- Activer slow query log MySQL
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
```

### 1.5 Taille des jobs en queue
```bash
php artisan queue:monitor database --max=1000
```

---

## 2. Tests

### État actuel
- ~276 méthodes de test documentées dans `TESTS_PLAN.md`
- 4 factories seulement (insuffisant pour 65 modèles)
- Tests Feature + Unit existants, couverture inconnue

### 2.1 P1 — Compléter les factories manquantes

Les modèles sans factory empêchent d'écrire des tests isolés.

| Priorité | Modèles sans factory à créer |
|----------|------------------------------|
| P1 | `Participant`, `Event`, `Workshop`, `Hotel`, `Reservation` |
| P1 | `Election`, `Vote`, `Candidate` |
| P2 | `Campaign`, `Form`, `FormResponse`, `AttestationTemplate` |
| P3 | Lookup tables (Country, Specialty, etc.) |

```bash
php artisan make:factory ParticipantFactory --model=Participant
# Répéter pour chaque modèle
```

### 2.2 P1 — Tests des Services (couche métier critique)

Chaque Service doit avoir une suite Unit complète avec mocks.

| Service | Tests existants | À ajouter |
|---------|-----------------|-----------|
| `ParticipantRegistrationService` | Partiel | Cas limites, doublons, codes |
| `WorkshopPricingService` | Partiel | Tarification par palier, edge cases |
| `PaymentService` | Partiel | Méthodes invalides, sponsorship |
| `VoteValidationService` | Partiel | Fraude, double vote |
| `PDFGenerationService` | ❌ | Génération, template manquant |
| `MagicLinkService` | ❌ | Expiration, réutilisation |
| `AccessCodeService` | ❌ | Génération, collision |
| `UserService` | ❌ | Création, métadonnées |

### 2.3 P1 — Tests des Jobs critiques

Les Jobs sont asynchrones et difficiles à déboguer en production.

```php
// Pattern recommandé
Queue::fake();
dispatch(new GenerateMassiveBadgePDF($event));
Queue::assertPushed(GenerateMassiveBadgePDF::class);

// Tester l'exécution réelle
$job = new GenerateMassiveBadgePDF($event);
$job->handle();
Storage::assertExists("badges/event-{$event->id}.pdf");
```

Jobs P1 à tester :
- `GenerateMassiveBadgePDF`
- `GenerateDynamicEmailAttestationJob`
- `SendCampaignEmail`
- `SMSingJob`

### 2.4 P2 — Tests des Imports/Exports Excel

17 importeurs = surface de bug élevée sur les données réelles.

```php
use Maatwebsite\Excel\Facades\Excel;

Excel::fake();
$this->post('/backend/import/participants', [
    'file' => UploadedFile::fake()->createWithContent(
        'participants.xlsx', $this->getFixture('participants.xlsx')
    )
]);
Excel::assertImported('participants.xlsx');
```

Ajouter des fixtures Excel dans `tests/Fixtures/` pour :
- `ImportParticipants`, `ImportHebergements`, `ImportAttestations`
- Cas d'erreur : colonnes manquantes, emails invalides, doublons

### 2.5 P2 — Tests des Observers

```php
// Vérifier que les audits sont bien créés
$participant = Participant::factory()->create();
$participant->update(['nom' => 'Nouveau Nom']);
$this->assertDatabaseHas('audits', [
    'auditable_type' => Participant::class,
    'auditable_id' => $participant->id,
    'event' => 'updated',
]);
```

### 2.6 P3 — Tests de régression Blade/Livewire

```php
$this->actingAs($admin)
     ->get("/backend/events/{$event->id}/participants")
     ->assertOk()
     ->assertSee('Liste des participants');
```

### Objectifs de couverture

| Namespace | Cible |
|-----------|-------|
| `App\Services\*` | ≥ 90% |
| `App\Jobs\*` | ≥ 80% |
| `App\Imports\*` | ≥ 70% |
| `App\Http\Controllers\*` | ≥ 60% |
| `App\Models\*` | ≥ 50% (scopes, accessors) |

---

## 3. Refactoring

### 3.1 P1 — Dégraisser les Controllers

**Problème :** ~20K LOC de controllers = logique métier non testable, non réutilisable.

**Règle :** Un controller ne doit que : valider → déléguer au Service → retourner la réponse.

```php
// AVANT (typique dans ce codebase)
public function store(Request $request, $eventId)
{
    $event = Event::findOrFail($eventId);
    // 80 lignes de logique inline...
    $participant = new Participant();
    $participant->nom = $request->nom;
    // calculs de prix, envoi d'email, génération de QR...
    $participant->save();
    return redirect()->back();
}

// APRÈS
public function store(StoreParticipantRequest $request, Event $event): RedirectResponse
{
    $participant = $this->registrationService->register($event, $request->validated());
    return redirect()->route('backend.participants.show', $participant)
                     ->with('success', 'Participant enregistré.');
}
```

**Actions :**
- [ ] Identifier les controllers > 300 LOC (priorité : Backend/)
- [ ] Extraire la logique vers les Services existants ou en créer de nouveaux
- [ ] Ajouter des Form Requests manquants (actuellement seulement 5)

### 3.2 P1 — Décomposer `Event.php` (8 500 LOC)

C'est le fichier le plus critique. Un modèle de 8.5K LOC est un signe d'accumulation.

**Approche :**
```
app/Models/
├── Event.php                    # Garder : relations, attributs de base
├── Concerns/
│   ├── HasParticipants.php      # Trait : scopes + méthodes participants
│   ├── HasWorkshops.php         # Trait : scopes + méthodes workshops
│   ├── HasAccommodations.php    # Trait : hôtels, réservations
│   ├── HasCommunications.php    # Trait : campagnes email/SMS
│   └── HasStatistics.php        # Trait : calculs stats, widgets
```

```php
// Exemple de Trait
trait HasParticipants
{
    public function participants(): HasMany { ... }
    public function registeredParticipants(): HasMany { ... }
    public function scopeWithParticipantCount(Builder $query): Builder { ... }
    public function getParticipantCountAttribute(): int { ... }
}
```

### 3.3 P2 — Réduire `routes/web.php` (889 LOC)

```php
// routes/web.php — garder uniquement les groupes
require __DIR__.'/backend/participants.php';
require __DIR__.'/backend/events.php';
require __DIR__.'/backend/accommodations.php';
require __DIR__.'/backend/workshops.php';
require __DIR__.'/backend/communications.php';
require __DIR__.'/backend/elections.php';
require __DIR__.'/frontend.php';
```

### 3.4 P2 — Form Requests systématiques

Actuellement 5 Form Requests pour ~85 controllers backend. Créer au minimum :

```bash
php artisan make:request Backend/StoreParticipantRequest
php artisan make:request Backend/UpdateParticipantRequest
php artisan make:request Backend/StoreReservationRequest
php artisan make:request Backend/StoreWorkshopRequest
# etc.
```

Avantages : validation centralisée, testable, controller allégé.

### 3.5 P2 — Typage strict PHP

```php
// Ajouter en tête de chaque fichier modifié
declare(strict_types=1);

// Typer tous les paramètres et retours des Services
public function register(Event $event, array $data): Participant
public function calculatePrice(Workshop $workshop, Participant $participant): float
```

### 3.6 P3 — Unifier la gestion des imports

17 importeurs avec probablement de la duplication. Créer une classe abstraite :

```php
abstract class BaseImport implements ToCollection, WithHeadings, WithValidation
{
    abstract protected function processRow(Collection $row): Model;
    abstract public function rules(): array;

    public function collection(Collection $rows): void
    {
        DB::transaction(function () use ($rows) {
            $rows->skip(1)->each(fn($row) => $this->processRow($row));
        });
    }
}
```

### 3.7 P3 — Constantes et Enums (PHP 8.1)

Remplacer les magic strings par des Enums :

```php
// AVANT
if ($participant->status === 'confirmed') { ... }
if ($payment->method === 'sponsorship') { ... }

// APRÈS
enum ParticipantStatus: string {
    case Confirmed = 'confirmed';
    case Pending = 'pending';
    case Cancelled = 'cancelled';
}

if ($participant->status === ParticipantStatus::Confirmed) { ... }
```

---

## 4. Performance

### 4.1 P1 — Éliminer les N+1 dans les listings

Les pages de listing participants/réservations/workshops sont les plus risquées.

```php
// AVANT (N+1 garanti)
$participants = Participant::where('event_id', $eventId)->get();
// Dans la vue : $participant->workshop->name (N requêtes)

// APRÈS
$participants = Participant::where('event_id', $eventId)
    ->with(['workshop', 'hotel', 'roomReservation.roomType', 'user'])
    ->get();
```

**Outil de détection :**
```bash
composer require --dev beyondcode/laravel-query-detector
# Ajoute une alerte dans le browser quand N+1 détecté
```

### 4.2 P1 — Index de base de données manquants

Colonnes à indexer en priorité (foreign keys souvent sans index) :

```php
// Migration à créer
Schema::table('participants', function (Blueprint $table) {
    $table->index('event_id');
    $table->index('status');
    $table->index(['event_id', 'status']); // Index composite
});

Schema::table('reservations', function (Blueprint $table) {
    $table->index(['event_id', 'hotel_id']);
    $table->index('participant_id');
});

Schema::table('participant_workshops', function (Blueprint $table) {
    $table->index(['participant_id', 'workshop_id']);
});

Schema::table('votes', function (Blueprint $table) {
    $table->index(['election_id', 'participant_id']);
});
```

```sql
-- Identifier les index manquants
EXPLAIN SELECT * FROM participants WHERE event_id = 1 AND status = 'confirmed';
-- type = ALL = pas d'index, type = ref = index utilisé
```

### 4.3 P1 — Optimiser les Jobs PDF/Email (les plus lourds)

```php
// GenerateMassiveBadgePDF : chunker au lieu de tout charger
public function handle(): void
{
    // AVANT : potentiellement 10 000 participants en RAM
    $participants = Participant::where('event_id', $this->eventId)->get();

    // APRÈS : 200 à la fois
    Participant::where('event_id', $this->eventId)
        ->chunkById(200, function (Collection $participants) {
            foreach ($participants as $participant) {
                $this->generateBadge($participant);
            }
        });
}
```

### 4.4 P2 — Cache des données statiques

```php
// EventStatWidget, listes de pays, spécialités : cacheable
public function getCountries(): Collection
{
    return Cache::remember('countries', now()->addDay(), fn() => Country::all());
}

// Stats d'événement : mettre en cache 5 min
public function getEventStats(Event $event): array
{
    return Cache::remember(
        "event_stats_{$event->id}",
        now()->addMinutes(5),
        fn() => $this->computeStats($event)
    );
}
```

Invalider le cache dans les Observers :
```php
// ParticipantObserver
public function created(Participant $participant): void
{
    Cache::forget("event_stats_{$participant->event_id}");
}
```

### 4.5 P2 — Pagination systématique

Remplacer `->get()` par `->paginate()` sur toutes les listes backend :

```php
// AVANT
$participants = Participant::where('event_id', $id)->get(); // 10 000 lignes

// APRÈS
$participants = Participant::where('event_id', $id)->paginate(50);
```

### 4.6 P2 — Queue driver Redis en production

```env
# .env production
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

# Configurer plusieurs queues par priorité
QUEUE_DEFAULT=default
QUEUE_PDF=pdf-heavy
QUEUE_EMAIL=emails
QUEUE_SMS=sms
```

```php
// Dispatcher les jobs sur la bonne queue
GenerateMassiveBadgePDF::dispatch($event)->onQueue('pdf-heavy');
SendCampaignEmail::dispatch($campaign)->onQueue('emails');
```

### 4.7 P3 — Compression et cache HTTP des assets

```php
// config/vite.php ou vite.config.js
// Activer la compression gzip/br des assets compilés

// Dans le middleware :
public function handle(Request $request, Closure $next): Response
{
    $response = $next($request);
    // Ajouter Cache-Control pour les assets statiques
    if ($request->is('*.js', '*.css')) {
        $response->header('Cache-Control', 'public, max-age=31536000, immutable');
    }
    return $response;
}
```

### 4.8 P3 — Lazy loading Blade + Livewire

Pour les tableaux de données lourds :
```blade
{{-- Utiliser wire:init pour charger en différé --}}
<livewire:participants-table :event="$event" wire:init="loadData" />
```

### Métriques de performance cibles

| Indicateur | Actuel (estimé) | Cible |
|-----------|-----------------|-------|
| Requêtes SQL par page listing | > 50 | < 10 |
| Temps de réponse page listing | > 1s | < 300ms |
| RAM Job PDF massif | > 512MB | < 128MB |
| Temps génération PDF 1000 badges | inconnu | < 60s |
| Index coverage (EXPLAIN) | inconnu | > 95% |

---

## 5. CI/CD

Le dossier `.github/workflows/` est vide. C'est un risque majeur.

### 5.1 P1 — Pipeline de tests GitHub Actions

Créer `.github/workflows/tests.yml` :

```yaml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: unified_test
        ports: ['3306:3306']

    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          coverage: xdebug

      - run: composer install --no-interaction --prefer-dist
      - run: cp .env.example .env.testing
      - run: php artisan key:generate --env=testing

      - name: Run tests with coverage
        run: php artisan test --coverage --min=60

      - name: PHPStan
        run: ./vendor/bin/phpstan analyse app --level=5
```

### 5.2 P2 — Pipeline Pint (formatage)

```yaml
name: Code Style

on: [push, pull_request]

jobs:
  pint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: composer install
      - run: ./vendor/bin/pint --test
```

### 5.3 P3 — Déploiement automatisé

```yaml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /var/www/unified
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php artisan migrate --force
            php artisan config:cache
            php artisan route:cache
            php artisan view:cache
            php artisan queue:restart
```

---

## 6. Roadmap consolidée

### Sprint 1 — Fondations (2 semaines)
> Objectif : mesurer et sécuriser

| # | Tâche | Effort | Impact |
|---|-------|--------|--------|
| 1 | Baseline coverage + PHPStan rapport | 0.5j | Visibilité |
| 2 | Créer factories P1 (5 modèles) | 1j | Déblocage tests |
| 3 | Tests Unit des 3 Services critiques | 2j | Sécurité métier |
| 4 | Index DB manquants (participants, reservations) | 0.5j | Perf immédiate |
| 5 | Détecter et corriger les 5 pires N+1 | 1j | Perf immédiate |
| 6 | GitHub Actions — pipeline de tests | 0.5j | CI |

### Sprint 2 — Refactoring (2-3 semaines)
> Objectif : réduire la dette technique

| # | Tâche | Effort | Impact |
|---|-------|--------|--------|
| 7 | Décomposer Event.php en Traits | 2j | Maintenabilité |
| 8 | Alléger les 5 controllers les plus gros | 3j | Testabilité |
| 9 | Splitter routes/web.php | 0.5j | Lisibilité |
| 10 | Form Requests pour les routes POST/PUT | 1j | Validation |
| 11 | Chunking dans les Jobs PDF/Email | 1j | Stabilité prod |

### Sprint 3 — Performance (2 semaines)
> Objectif : scalabilité

| # | Tâche | Effort | Impact |
|---|-------|--------|--------|
| 12 | Cache Redis des stats d'événement | 1j | Perf |
| 13 | Pagination sur tous les listings | 1j | Perf + mémoire |
| 14 | Queue Redis + files séparées | 0.5j | Scalabilité |
| 15 | Compléter tests Imports/Exports | 2j | Fiabilité data |

### Sprint 4 — Qualité continue (ongoing)
| # | Tâche | Effort | Impact |
|---|-------|--------|--------|
| 16 | Enums PHP 8.1 pour les statuts | 1j | Robustesse |
| 17 | Typage strict progressif | ongoing | Fiabilité |
| 18 | Couverture ≥ 70% globale | ongoing | Confiance |
| 19 | Pipeline déploiement auto | 1j | DevOps |

---

## Commandes de référence rapide

```bash
# Tests
php artisan test
php artisan test --coverage --min=60
php artisan test --filter=ParticipantRegistrationServiceTest

# Analyse statique
./vendor/bin/phpstan analyse app --level=5
./vendor/bin/pint --test

# Performance
php artisan telescope:install  # Si APM voulu
php artisan queue:work --queue=pdf-heavy,emails,sms,default

# Optimisation production
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan optimize

# Debug
php artisan tinker
Event::find(1)->loadMissing('participants')->participants->count();
```

---

*Plan généré le 2026-05-05. Revoir les priorités en fonction des résultats du diagnostic initial (§1).*
