'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, ]); } }