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>
This commit is contained in:
294
tests/Feature/BudgetPerformanceTest.php
Normal file
294
tests/Feature/BudgetPerformanceTest.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user