diff --git a/backend/app/Http/Controllers/Web/OnboardingController.php b/backend/app/Http/Controllers/Web/OnboardingController.php index e7b367c3..b09eda77 100644 --- a/backend/app/Http/Controllers/Web/OnboardingController.php +++ b/backend/app/Http/Controllers/Web/OnboardingController.php @@ -9,6 +9,7 @@ use App\Models\TemplateStep; use App\Services\WorkOrder\WorkOrderService; use App\Support\ModuleRegistry; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; @@ -119,6 +120,13 @@ public function step3(Request $request) public function storeStep3(Request $request) { + // The step is not repeatable: a resubmit (double click, browser back, + // Inertia retry) would otherwise insert a second template and violate + // the (product_type_id, version) unique index. + if ($request->session()->has('onboarding.template_id')) { + return redirect()->route('onboarding.step4'); + } + $validated = $request->validate([ 'name' => 'required|string|max:255', 'steps' => 'required|array|min:1', @@ -128,20 +136,40 @@ public function storeStep3(Request $request) $productTypeId = $request->session()->get('onboarding.product_type_id'); - $template = ProcessTemplate::create([ - 'product_type_id' => $productTypeId, - 'name' => $validated['name'], - 'version' => 1, - 'is_active' => true, - ]); - - foreach ($validated['steps'] as $i => $stepData) { - TemplateStep::create([ - 'process_template_id' => $template->id, - 'step_number' => $i + 1, - 'name' => $stepData['name'], - 'estimated_duration_minutes' => $stepData['estimated_duration_minutes'] ?? null, - ]); + try { + $template = DB::transaction(function () use ($validated, $productTypeId) { + // Same versioning rule as the admin UI and the API: never assume v1. + $nextVersion = ProcessTemplate::where('product_type_id', $productTypeId)->max('version') + 1; + + $template = ProcessTemplate::create([ + 'product_type_id' => $productTypeId, + 'name' => $validated['name'], + 'version' => $nextVersion, + 'is_active' => true, + ]); + + foreach ($validated['steps'] as $i => $stepData) { + TemplateStep::create([ + 'process_template_id' => $template->id, + 'step_number' => $i + 1, + 'name' => $stepData['name'], + 'estimated_duration_minutes' => $stepData['estimated_duration_minutes'] ?? null, + ]); + } + + return $template; + }); + } catch (UniqueConstraintViolationException $e) { + // The session guard above stops sequential replays, but two truly + // concurrent same-session POSTs (rapid double click) can both read an + // empty table and pick the same (product_type_id, version) — a + // FOR UPDATE lock can't serialise the first insert because there are + // no rows to lock yet. Rather than a cross-writer lock, treat the loser + // idempotently: a template now exists, so adopt the latest one and + // continue instead of surfacing a 500. + $template = ProcessTemplate::where('product_type_id', $productTypeId) + ->orderByDesc('version') + ->firstOrFail(); } $request->session()->put('onboarding.template_id', $template->id); diff --git a/backend/tests/Feature/OnboardingWizardTest.php b/backend/tests/Feature/OnboardingWizardTest.php index 8a62d039..14f49a48 100644 --- a/backend/tests/Feature/OnboardingWizardTest.php +++ b/backend/tests/Feature/OnboardingWizardTest.php @@ -110,6 +110,116 @@ public function test_step3_creates_template_with_steps(): void $this->assertEquals(3, ProcessTemplate::first()->steps()->count()); } + public function test_step3_resubmit_does_not_create_duplicate_template(): void + { + $pt = ProductType::factory()->create(); + + $payload = [ + 'name' => 'Assembly Process', + 'steps' => [['name' => 'Preparation', 'estimated_duration_minutes' => 10]], + ]; + + $first = $this->actingAs($this->admin) + ->withSession(['onboarding.product_type_id' => $pt->id]) + ->post(route('onboarding.step3'), $payload); + + $first->assertRedirect(route('onboarding.step4')) + ->assertSessionHas('onboarding.template_id'); + + // Reuse the exact template id the controller stashed in the session, so the + // replay mirrors the real browser state rather than a proxy lookup. + $templateId = $first->getSession()->get('onboarding.template_id'); + + // Same session replays the step (double click / browser back / Inertia retry). + $this->actingAs($this->admin) + ->withSession([ + 'onboarding.product_type_id' => $pt->id, + 'onboarding.template_id' => $templateId, + ]) + ->post(route('onboarding.step3'), $payload) + ->assertRedirect(route('onboarding.step4')); + + $this->assertEquals(1, ProcessTemplate::count()); + $this->assertEquals(1, ProcessTemplate::first()->steps()->count()); + } + + public function test_step3_first_template_for_product_type_gets_version_1(): void + { + $pt = ProductType::factory()->create(); + + $this->actingAs($this->admin) + ->withSession(['onboarding.product_type_id' => $pt->id]) + ->post(route('onboarding.step3'), [ + 'name' => 'First Process', + 'steps' => [['name' => 'Preparation', 'estimated_duration_minutes' => 10]], + ]) + ->assertRedirect(route('onboarding.step4')); + + $this->assertDatabaseHas('process_templates', [ + 'product_type_id' => $pt->id, + 'name' => 'First Process', + 'version' => 1, + ]); + } + + public function test_step3_validation_errors_on_empty_payload(): void + { + $pt = ProductType::factory()->create(); + + $this->actingAs($this->admin) + ->withSession(['onboarding.product_type_id' => $pt->id]) + ->post(route('onboarding.step3'), []) + ->assertSessionHasErrors(['name', 'steps']); + + $this->assertEquals(0, ProcessTemplate::count()); + } + + public function test_step3_is_forbidden_for_guests_and_non_admins(): void + { + $pt = ProductType::factory()->create(); + $payload = [ + 'name' => 'Assembly Process', + 'steps' => [['name' => 'Preparation']], + ]; + + // Guest → redirected to login, no template created. + $this->withSession(['onboarding.product_type_id' => $pt->id]) + ->post(route('onboarding.step3'), $payload) + ->assertRedirect(route('login')); + + // Authenticated but wrong role → 403 (onboarding is Admin-only). + $operator = User::factory()->create(); + $operator->assignRole('Operator'); + + $this->actingAs($operator) + ->withSession(['onboarding.product_type_id' => $pt->id]) + ->post(route('onboarding.step3'), $payload) + ->assertForbidden(); + + $this->assertEquals(0, ProcessTemplate::count()); + } + + public function test_step3_assigns_next_version_when_product_type_already_has_template(): void + { + $pt = ProductType::factory()->create(); + ProcessTemplate::factory()->create(['product_type_id' => $pt->id, 'version' => 1]); + + $response = $this->actingAs($this->admin) + ->withSession(['onboarding.product_type_id' => $pt->id]) + ->post(route('onboarding.step3'), [ + 'name' => 'Second Process', + 'steps' => [['name' => 'Preparation', 'estimated_duration_minutes' => 10]], + ]); + + $response->assertRedirect(route('onboarding.step4')); + $this->assertDatabaseHas('process_templates', [ + 'product_type_id' => $pt->id, + 'name' => 'Second Process', + 'version' => 2, + ]); + $this->assertEquals(2, ProcessTemplate::where('product_type_id', $pt->id)->count()); + } + public function test_step4_creates_work_order(): void { $line = Line::factory()->create();