-
-
{{ s.nom }}
+
+
+
+
+
+
+
{{ s.nom }}
+
{{ s.description }}
+
-
-
{{ s.description }}
-
+
{{ s.users_count }} utilisateur(s)
{{ s.commandes_count }} commande(s)
+
+
diff --git a/resources/js/app.js b/resources/js/app.js
index a2a2839..8464d48 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -6,6 +6,14 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createApp, h } from 'vue';
import { ZiggyVue } from '../../vendor/tightenco/ziggy';
+// FontAwesome
+import { library } from '@fortawesome/fontawesome-svg-core';
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
+import { fas } from '@fortawesome/free-solid-svg-icons';
+import { far } from '@fortawesome/free-regular-svg-icons';
+import { fab } from '@fortawesome/free-brands-svg-icons';
+library.add(fas, far, fab);
+
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({
@@ -19,6 +27,7 @@ createInertiaApp({
return createApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue)
+ .component('font-awesome-icon', FontAwesomeIcon)
.mount(el);
},
progress: {
diff --git a/tests/Feature/BudgetMetierTest.php b/tests/Feature/BudgetMetierTest.php
new file mode 100644
index 0000000..6d3b01d
--- /dev/null
+++ b/tests/Feature/BudgetMetierTest.php
@@ -0,0 +1,255 @@
+ $name, 'guard_name' => 'web']);
+ }
+
+ private function userWithRole(string $role, ?Service $service = null): User
+ {
+ $service ??= Service::factory()->create();
+ $user = User::factory()->create(['service_id' => $service->id, 'email_verified_at' => now()]);
+ $user->assignRole($this->createRole($role));
+ return $user;
+ }
+
+ private function makeBudget(Service $service, string $statut = 'preparation'): Budget
+ {
+ return Budget::create([
+ 'service_id' => $service->id,
+ 'annee' => now()->year,
+ 'type_budget' => 'agglo',
+ 'statut' => $statut,
+ ]);
+ }
+
+ private function makeLigne(Budget $budget, float $propose = 5000, ?float $arbitre = null): LigneBudget
+ {
+ return LigneBudget::create([
+ 'budget_id' => $budget->id,
+ 'nom' => 'Ligne test',
+ 'type_depense' => 'investissement',
+ 'montant_propose' => $propose,
+ 'montant_arbitre' => $arbitre,
+ ]);
+ }
+
+ private function makeCommande(array $overrides = []): \App\Models\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 \App\Models\Commande::create(array_merge([
+ 'service_id' => $service,
+ 'user_id' => $user,
+ 'numero_commande' => 'CMD-METIER-' . $counter++,
+ 'objet' => 'Commande test',
+ 'statut' => 'brouillon',
+ 'montant_ht' => 1000,
+ 'montant_ttc' => 1200,
+ 'date_demande' => now()->toDateString(),
+ ], $overrides));
+ }
+
+ // -------------------------------------------------------------------------
+ // Historique des arbitrages
+ // -------------------------------------------------------------------------
+
+ public function test_arbitrage_creates_historique_entry(): void
+ {
+ $service = Service::factory()->create();
+ $raf = $this->userWithRole('raf', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget, 1000);
+
+ $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
+ 'statut_arbitrage' => 'accepte_dsi',
+ 'montant_arbitre' => 800,
+ 'commentaire' => 'Réduit suite révision priorités',
+ ]);
+
+ $this->assertDatabaseHas('historique_budgets', [
+ 'ligne_budget_id' => $ligne->id,
+ 'user_id' => $raf->id,
+ 'ancien_statut' => 'brouillon',
+ 'nouveau_statut' => 'accepte_dsi',
+ 'ancien_montant_arbitre' => null,
+ 'nouveau_montant_arbitre' => 800,
+ 'commentaire' => 'Réduit suite révision priorités',
+ ]);
+ }
+
+ public function test_multiple_arbitrages_build_full_history(): void
+ {
+ $service = Service::factory()->create();
+ $raf = $this->userWithRole('raf', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget, 1000);
+
+ $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
+ 'statut_arbitrage' => 'accepte_dsi',
+ 'montant_arbitre' => 800,
+ ]);
+
+ $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
+ 'statut_arbitrage' => 'valide_definitif',
+ 'montant_arbitre' => 750,
+ ]);
+
+ $this->assertSame(2, HistoriqueBudget::where('ligne_budget_id', $ligne->id)->count());
+
+ // Le deuxième arbitrage (le plus récent) doit tracer le passage de accepte_dsi → valide_definitif
+ $last = HistoriqueBudget::where('ligne_budget_id', $ligne->id)->oldest()->skip(1)->first();
+ $this->assertEquals('accepte_dsi', $last->ancien_statut);
+ $this->assertEquals('valide_definitif', $last->nouveau_statut);
+ $this->assertEquals(800, (float) $last->ancien_montant_arbitre);
+ $this->assertEquals(750, (float) $last->nouveau_montant_arbitre);
+ }
+
+ public function test_historique_is_included_in_budget_show(): void
+ {
+ $service = Service::factory()->create();
+ $raf = $this->userWithRole('raf', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget, 1000);
+
+ $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
+ 'statut_arbitrage' => 'accepte_dsi',
+ 'montant_arbitre' => 800,
+ 'commentaire' => 'Premier arbitrage',
+ ]);
+
+ $response = $this->actingAs($raf)->get(route('budgets.show', $budget));
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) =>
+ $page->has('lignes.0.historique', 1)
+ ->where('lignes.0.historique.0.commentaire', 'Premier arbitrage')
+ ->where('lignes.0.historique.0.user', $raf->name)
+ );
+ }
+
+ public function test_historique_deleted_when_ligne_deleted(): void
+ {
+ $service = Service::factory()->create();
+ $raf = $this->userWithRole('raf', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget, 1000);
+
+ $this->actingAs($raf)->patch(route('lignes-budget.arbitrer', $ligne), [
+ 'statut_arbitrage' => 'accepte_dsi',
+ 'montant_arbitre' => 800,
+ ]);
+
+ $ligneId = $ligne->id;
+ $this->actingAs($this->userWithRole('admin'))->delete(route('lignes-budget.destroy', $ligne));
+
+ $this->assertDatabaseMissing('historique_budgets', ['ligne_budget_id' => $ligneId]);
+ }
+
+ // -------------------------------------------------------------------------
+ // Blocage des commandes si budget insuffisant
+ // -------------------------------------------------------------------------
+
+ public function test_commande_creation_blocked_when_budget_exceeded(): void
+ {
+ $service = Service::factory()->create();
+ $acheteur = $this->userWithRole('acheteur', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget, propose: 5000, arbitre: 1000);
+
+ // Commande existante qui consomme 800 € TTC
+ $this->makeCommande([
+ 'ligne_budget_id' => $ligne->id,
+ 'service_id' => $service->id,
+ 'user_id' => $acheteur->id,
+ 'montant_ttc' => 800,
+ 'statut' => 'validee',
+ ]);
+
+ // Tentative de créer une commande avec une ligne à 500 € HT + 20% TVA = 600 € TTC
+ // Reste dispo : 1000 - 800 = 200 € → 600 > 200 → bloqué
+ $response = $this->actingAs($acheteur)->post(route('commandes.store'), [
+ 'service_id' => $service->id,
+ 'objet' => 'Dépassement test',
+ 'priorite' => 'normale',
+ 'date_demande' => now()->toDateString(),
+ 'ligne_budget_id' => $ligne->id,
+ 'lignes' => [
+ ['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 500, 'taux_tva' => 20],
+ ],
+ ]);
+
+ $response->assertSessionHasErrors('ligne_budget_id');
+ }
+
+ public function test_commande_creation_allowed_when_budget_sufficient(): void
+ {
+ $service = Service::factory()->create();
+ $acheteur = $this->userWithRole('acheteur', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget, propose: 5000, arbitre: 2000);
+
+ // Commande existante de 800 € TTC, reste 1200 €
+ $this->makeCommande([
+ 'ligne_budget_id' => $ligne->id,
+ 'service_id' => $service->id,
+ 'user_id' => $acheteur->id,
+ 'montant_ttc' => 800,
+ 'statut' => 'validee',
+ ]);
+
+ // Nouvelle ligne à 100 € HT + 20% = 120 € TTC → OK (800+120=920 < 2000)
+ $response = $this->actingAs($acheteur)->post(route('commandes.store'), [
+ 'service_id' => $service->id,
+ 'objet' => 'Commande dans le budget',
+ 'priorite' => 'normale',
+ 'date_demande' => now()->toDateString(),
+ 'ligne_budget_id' => $ligne->id,
+ 'lignes' => [
+ ['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 100, 'taux_tva' => 20],
+ ],
+ ]);
+
+ $response->assertSessionDoesntHaveErrors('ligne_budget_id');
+ }
+
+ public function test_commande_creation_not_blocked_when_ligne_not_yet_arbitree(): void
+ {
+ $service = Service::factory()->create();
+ $acheteur = $this->userWithRole('acheteur', $service);
+ $budget = $this->makeBudget($service);
+ // Ligne sans montant_arbitre
+ $ligne = $this->makeLigne($budget, propose: 1000, arbitre: null);
+
+ // Ligne à 9000 € HT → normalement dépasserait, mais pas arbitrée = pas de blocage
+ $response = $this->actingAs($acheteur)->post(route('commandes.store'), [
+ 'service_id' => $service->id,
+ 'objet' => 'Commande avant arbitrage',
+ 'priorite' => 'normale',
+ 'date_demande' => now()->toDateString(),
+ 'ligne_budget_id' => $ligne->id,
+ 'lignes' => [
+ ['designation' => 'Article test', 'quantite' => 1, 'prix_unitaire_ht' => 9000, 'taux_tva' => 20],
+ ],
+ ]);
+
+ $response->assertSessionDoesntHaveErrors('ligne_budget_id');
+ }
+}
diff --git a/tests/Feature/BudgetPerformanceTest.php b/tests/Feature/BudgetPerformanceTest.php
new file mode 100644
index 0000000..1bfda6c
--- /dev/null
+++ b/tests/Feature/BudgetPerformanceTest.php
@@ -0,0 +1,294 @@
+ '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,
+ ]);
+ }
+}
diff --git a/tests/Feature/BudgetSecurityTest.php b/tests/Feature/BudgetSecurityTest.php
new file mode 100644
index 0000000..440f66e
--- /dev/null
+++ b/tests/Feature/BudgetSecurityTest.php
@@ -0,0 +1,354 @@
+ $name, 'guard_name' => 'web']);
+ }
+
+ private function userWithRole(string $role, ?Service $service = null): User
+ {
+ $service ??= Service::factory()->create();
+ $user = User::factory()->create(['service_id' => $service->id, 'email_verified_at' => now()]);
+ $user->assignRole($this->createRole($role));
+ return $user;
+ }
+
+ private function makeBudget(Service $service, string $statut = 'preparation'): Budget
+ {
+ return Budget::create([
+ 'service_id' => $service->id,
+ 'annee' => 2026,
+ 'type_budget' => 'agglo',
+ 'statut' => $statut,
+ ]);
+ }
+
+ private function makeLigne(Budget $budget): LigneBudget
+ {
+ return LigneBudget::create([
+ 'budget_id' => $budget->id,
+ 'nom' => 'Ligne test',
+ 'type_depense' => 'investissement',
+ 'montant_propose' => 1000,
+ ]);
+ }
+
+ // -------------------------------------------------------------------------
+ // LigneBudgetController::destroy — faille critique
+ // -------------------------------------------------------------------------
+
+ public function test_responsable_can_delete_ligne_of_own_service(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
+
+ $response->assertRedirect();
+ $this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]);
+ }
+
+ public function test_responsable_cannot_delete_ligne_of_another_service(): void
+ {
+ $otherService = Service::factory()->create();
+ $myService = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $myService);
+ $budget = $this->makeBudget($otherService);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
+ }
+
+ public function test_unauthenticated_user_cannot_delete_ligne(): void
+ {
+ $service = Service::factory()->create();
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->delete(route('lignes-budget.destroy', $ligne));
+
+ $response->assertRedirect(route('login'));
+ $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
+ }
+
+ public function test_cannot_delete_ligne_when_budget_is_locked(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $service);
+ $budget = $this->makeBudget($service, 'valide');
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->delete(route('lignes-budget.destroy', $ligne));
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id]);
+ }
+
+ public function test_admin_can_delete_ligne_of_any_service(): void
+ {
+ $otherService = Service::factory()->create();
+ $admin = $this->userWithRole('admin');
+ $budget = $this->makeBudget($otherService);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($admin)->delete(route('lignes-budget.destroy', $ligne));
+
+ $response->assertRedirect();
+ $this->assertDatabaseMissing('ligne_budgets', ['id' => $ligne->id]);
+ }
+
+ // -------------------------------------------------------------------------
+ // LigneBudgetController::update — vérification service_id
+ // -------------------------------------------------------------------------
+
+ public function test_responsable_can_update_ligne_of_own_service(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
+ 'nom' => 'Ligne modifiée',
+ 'type_depense' => 'fonctionnement',
+ 'montant_propose' => 2000,
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne modifiée']);
+ }
+
+ public function test_responsable_cannot_update_ligne_of_another_service(): void
+ {
+ $otherService = Service::factory()->create();
+ $myService = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $myService);
+ $budget = $this->makeBudget($otherService);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
+ 'nom' => 'Tentative de modification',
+ 'type_depense' => 'fonctionnement',
+ 'montant_propose' => 9999,
+ ]);
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('ligne_budgets', ['id' => $ligne->id, 'nom' => 'Ligne test']);
+ }
+
+ public function test_cannot_update_ligne_when_budget_is_locked(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $service);
+ $budget = $this->makeBudget($service, 'arbitrage_direction');
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->put(route('lignes-budget.update', $ligne), [
+ 'nom' => 'Tentative modif budget verrouillé',
+ 'type_depense' => 'investissement',
+ 'montant_propose' => 5000,
+ ]);
+
+ $response->assertForbidden();
+ }
+
+ // -------------------------------------------------------------------------
+ // LigneBudgetController::store — vérification service_id
+ // -------------------------------------------------------------------------
+
+ public function test_responsable_can_add_ligne_to_own_budget(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $service);
+ $budget = $this->makeBudget($service);
+
+ $response = $this->actingAs($user)->post(route('lignes-budget.store'), [
+ 'budget_id' => $budget->id,
+ 'nom' => 'Nouvelle ligne',
+ 'type_depense' => 'investissement',
+ 'montant_propose' => 500,
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('ligne_budgets', ['budget_id' => $budget->id, 'nom' => 'Nouvelle ligne']);
+ }
+
+ public function test_responsable_cannot_add_ligne_to_another_service_budget(): void
+ {
+ $otherService = Service::factory()->create();
+ $myService = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $myService);
+ $budget = $this->makeBudget($otherService);
+
+ $response = $this->actingAs($user)->post(route('lignes-budget.store'), [
+ 'budget_id' => $budget->id,
+ 'nom' => 'Ligne injectée',
+ 'type_depense' => 'investissement',
+ 'montant_propose' => 500,
+ ]);
+
+ $response->assertForbidden();
+ $this->assertDatabaseMissing('ligne_budgets', ['budget_id' => $budget->id]);
+ }
+
+ // -------------------------------------------------------------------------
+ // BudgetController::update — statut réservé aux admin/directeur
+ // -------------------------------------------------------------------------
+
+ public function test_directeur_can_change_budget_status(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('directeur', $service);
+ $budget = $this->makeBudget($service, 'preparation');
+
+ $response = $this->actingAs($user)->put(route('budgets.update', $budget), [
+ 'statut' => 'arbitrage_dsi',
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'arbitrage_dsi']);
+ }
+
+ public function test_responsable_cannot_change_budget_status(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $service);
+ $budget = $this->makeBudget($service, 'preparation');
+
+ $response = $this->actingAs($user)->put(route('budgets.update', $budget), [
+ 'statut' => 'valide',
+ ]);
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('budgets', ['id' => $budget->id, 'statut' => 'preparation']);
+ }
+
+ public function test_acheteur_cannot_change_budget_status(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('acheteur', $service);
+ $budget = $this->makeBudget($service, 'preparation');
+
+ $response = $this->actingAs($user)->put(route('budgets.update', $budget), [
+ 'statut' => 'valide',
+ ]);
+
+ $response->assertForbidden();
+ }
+
+ // -------------------------------------------------------------------------
+ // BudgetController exports — validation des paramètres enum
+ // -------------------------------------------------------------------------
+
+ public function test_export_excel_ignores_invalid_type_parameter(): void
+ {
+ $admin = $this->userWithRole('admin');
+
+ // Paramètre type invalide — ne doit pas causer d'erreur ni être utilisé
+ $response = $this->actingAs($admin)->get(route('budgets.export.excel', [
+ 'annee' => 2026,
+ 'type' => 'INJECTED_VALUE; DROP TABLE budgets;',
+ ]));
+
+ // Doit retourner le fichier (200) sans planter
+ $response->assertOk();
+ }
+
+ public function test_export_pdf_ignores_invalid_envelope_parameter(): void
+ {
+ $admin = $this->userWithRole('admin');
+
+ $response = $this->actingAs($admin)->get(route('budgets.export.pdf', [
+ 'annee' => 2026,
+ 'envelope' => 'invalid_value',
+ ]));
+
+ $response->assertOk();
+ }
+
+ public function test_export_accepts_valid_type_parameter(): void
+ {
+ $admin = $this->userWithRole('admin');
+
+ $response = $this->actingAs($admin)->get(route('budgets.export.excel', [
+ 'annee' => 2026,
+ 'type' => 'investissement',
+ ]));
+
+ $response->assertOk();
+ }
+
+ public function test_export_accepts_valid_envelope_parameter(): void
+ {
+ $admin = $this->userWithRole('admin');
+
+ $response = $this->actingAs($admin)->get(route('budgets.export.pdf', [
+ 'annee' => 2026,
+ 'envelope' => 'agglo',
+ ]));
+
+ $response->assertOk();
+ }
+
+ // -------------------------------------------------------------------------
+ // Arbitrage — réservé aux admin/directeur/raf
+ // -------------------------------------------------------------------------
+
+ public function test_raf_can_arbitrer_ligne(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('raf', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [
+ 'statut_arbitrage' => 'accepte_dsi',
+ 'montant_arbitre' => 800,
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('ligne_budgets', [
+ 'id' => $ligne->id,
+ 'statut_arbitrage' => 'accepte_dsi',
+ 'montant_arbitre' => 800,
+ ]);
+ }
+
+ public function test_responsable_cannot_arbitrer_ligne(): void
+ {
+ $service = Service::factory()->create();
+ $user = $this->userWithRole('responsable', $service);
+ $budget = $this->makeBudget($service);
+ $ligne = $this->makeLigne($budget);
+
+ $response = $this->actingAs($user)->patch(route('lignes-budget.arbitrer', $ligne), [
+ 'statut_arbitrage' => 'valide_definitif',
+ 'montant_arbitre' => 1000,
+ ]);
+
+ $response->assertForbidden();
+ $this->assertDatabaseHas('ligne_budgets', [
+ 'id' => $ligne->id,
+ 'statut_arbitrage' => 'brouillon',
+ ]);
+ }
+}