Files
dsi-commander/tests/Feature/BudgetPerformanceTest.php
jeremy bayse 0ad77de412 feat: module budgets complet avec sécurité, performance et métier
## Fonctionnalités
- Module Budgets : enveloppes, lignes budgétaires, arbitrage DSI/Direction
- Suivi de l'exécution budgétaire avec alertes visuelles (dépassement, seuil 80%)
- Blocage des commandes si budget insuffisant (store + update)
- Audit trail complet des arbitrages via HistoriqueBudget
- Page d'index budgets refaite en tableau avec filtres et tri côté client
- Page Services avec sélecteur d'icônes FontAwesome (solid + regular + brands)

## Sécurité
- BudgetPolicy centralisée (viewAny, view, create, update, addLigne, updateLigne, deleteLigne, arbitrerLigne)
- Autorisation sur tous les endpoints LigneBudget et Budget
- Protection XSS : remplacement v-html par classes dynamiques
- Validation des paramètres d'export (type, envelope)
- Validation montant_arbitre ≤ montant_propose côté serveur

## Performance
- Eager loading lignes.commandes.commune dans execution() et exportPdf()
- Calculs montant_consomme/engage en mémoire sur collections déjà chargées
- Null-safety sur montant_arbitre dans getMontantDisponibleAttribute

## Technique
- Migration historique_budgets, budgets, ligne_budgets, rôle raf
- SearchableSelect avec affichage du disponible budgétaire
- FontAwesome enregistré globalement (fas, far, fab)
- 33 tests Feature (sécurité, performance, métier)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:20:05 +02:00

295 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
use App\Models\Budget;
use App\Models\Commande;
use App\Models\LigneBudget;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class BudgetPerformanceTest extends TestCase
{
use RefreshDatabase;
private function adminUser(): User
{
Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
$user = User::factory()->create([
'service_id' => Service::factory()->create()->id,
'email_verified_at' => now(),
]);
$user->assignRole('admin');
return $user;
}
private function rafUser(Service $service): User
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$user = User::factory()->create([
'service_id' => $service->id,
'email_verified_at' => now(),
]);
$user->assignRole('raf');
return $user;
}
private function makeCommande(array $overrides = []): Commande
{
static $counter = 1;
$service = $overrides['service_id'] ?? Service::factory()->create()->id;
$user = $overrides['user_id'] ?? User::factory()->create(['service_id' => $service, 'email_verified_at' => now()])->id;
return Commande::create(array_merge([
'service_id' => $service,
'user_id' => $user,
'numero_commande' => 'CMD-PERF-' . $counter++,
'objet' => 'Commande de test',
'statut' => 'brouillon',
'montant_ht' => 1000,
'montant_ttc' => 1200,
'date_demande' => now()->toDateString(),
], $overrides));
}
private function makeBudgetWithLignes(int $nbLignes = 3, int $commandesPerLigne = 2): array
{
$service = Service::factory()->create();
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'preparation',
]);
$lignes = collect();
for ($i = 0; $i < $nbLignes; $i++) {
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => "Ligne $i",
'type_depense' => $i % 2 === 0 ? 'investissement' : 'fonctionnement',
'montant_propose' => 10000,
'montant_arbitre' => 8000,
]);
for ($j = 0; $j < $commandesPerLigne; $j++) {
$this->makeCommande([
'ligne_budget_id' => $ligne->id,
'service_id' => $service->id,
'statut' => $j === 0 ? 'validee' : 'brouillon',
]);
}
$lignes->push($ligne);
}
return [$budget, $lignes, $service];
}
// -------------------------------------------------------------------------
// N+1 — execution()
// -------------------------------------------------------------------------
public function test_execution_does_not_trigger_n_plus_1_queries(): void
{
[$budget, $lignes, $service] = $this->makeBudgetWithLignes(nbLignes: 5, commandesPerLigne: 3);
$admin = $this->adminUser();
// Warmup — on ignore la première requête de setup
DB::flushQueryLog();
DB::enableQueryLog();
$this->actingAs($admin)->get(route('budgets.execution', ['annee' => now()->year]));
$queries = DB::getQueryLog();
DB::disableQueryLog();
// Sans N+1 : ~5 requêtes max (session, user, lignes, eager loads)
// Avec N+1 : 3 requêtes × nb_lignes supplémentaires
// On vérifie qu'on reste sous un seuil raisonnable (15 = large marge)
$this->assertLessThan(
15,
count($queries),
sprintf(
'Trop de requêtes SQL dans execution() : %d requêtes pour 5 lignes. Probable N+1.',
count($queries)
)
);
}
// -------------------------------------------------------------------------
// N+1 — exportPdf()
// -------------------------------------------------------------------------
public function test_export_pdf_does_not_trigger_n_plus_1_queries(): void
{
[$budget, $lignes, $service] = $this->makeBudgetWithLignes(nbLignes: 5, commandesPerLigne: 3);
$admin = $this->adminUser();
DB::flushQueryLog();
DB::enableQueryLog();
$this->actingAs($admin)->get(route('budgets.export.pdf', ['annee' => now()->year]));
$queries = DB::getQueryLog();
DB::disableQueryLog();
$this->assertLessThan(
15,
count($queries),
sprintf(
'Trop de requêtes SQL dans exportPdf() : %d requêtes pour 5 lignes. Probable N+1.',
count($queries)
)
);
}
// -------------------------------------------------------------------------
// null-safety — montant_disponible quand montant_arbitre est null
// -------------------------------------------------------------------------
public function test_montant_disponible_is_zero_when_montant_arbitre_is_null(): void
{
$service = Service::factory()->create();
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'preparation',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne sans arbitrage',
'type_depense' => 'investissement',
'montant_propose' => 5000,
'montant_arbitre' => null,
]);
// Avant la correction, ceci retournait une valeur imprévisible ou une erreur
$this->assertSame(0.0, $ligne->montant_disponible);
$this->assertIsFloat($ligne->montant_disponible);
}
public function test_montant_disponible_is_correct_with_arbitre_set(): void
{
$service = Service::factory()->create();
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'preparation',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne arbitrée',
'type_depense' => 'investissement',
'montant_propose' => 5000,
'montant_arbitre' => 4000,
]);
// Pas de commandes : disponible = arbitré = 4000
$this->assertSame(4000.0, $ligne->montant_disponible);
}
// -------------------------------------------------------------------------
// Validation montant_arbitre <= montant_propose
// -------------------------------------------------------------------------
public function test_arbitrage_rejects_montant_arbitre_exceeding_montant_propose(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$service = Service::factory()->create();
$raf = $this->rafUser($service);
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'arbitrage_dsi',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne à arbitrer',
'type_depense' => 'investissement',
'montant_propose' => 1000,
]);
$response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 1500, // > montant_propose
]);
$response->assertSessionHasErrors('montant_arbitre');
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'montant_arbitre' => null, // inchangé
]);
}
public function test_arbitrage_accepts_montant_arbitre_equal_to_montant_propose(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$service = Service::factory()->create();
$raf = $this->rafUser($service);
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'arbitrage_dsi',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne à arbitrer',
'type_depense' => 'investissement',
'montant_propose' => 1000,
]);
$response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 1000, // = montant_propose, OK
]);
$response->assertRedirect();
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'montant_arbitre' => 1000,
'statut_arbitrage' => 'accepte_dsi',
]);
}
public function test_arbitrage_accepts_montant_arbitre_below_montant_propose(): void
{
Role::firstOrCreate(['name' => 'raf', 'guard_name' => 'web']);
$service = Service::factory()->create();
$raf = $this->rafUser($service);
$budget = Budget::create([
'service_id' => $service->id,
'annee' => now()->year,
'type_budget' => 'agglo',
'statut' => 'arbitrage_dsi',
]);
$ligne = LigneBudget::create([
'budget_id' => $budget->id,
'nom' => 'Ligne à arbitrer',
'type_depense' => 'investissement',
'montant_propose' => 1000,
]);
$response = $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
'statut_arbitrage' => 'accepte_dsi',
'montant_arbitre' => 750,
]);
$response->assertRedirect();
$this->assertDatabaseHas('ligne_budgets', [
'id' => $ligne->id,
'montant_arbitre' => 750,
]);
}
}