adminUser = User::factory()->create(['role' => Role::ADMIN]); $this->managerUser = User::factory()->create(['role' => Role::MANAGER]); $this->brigadierUser = User::factory()->create(['role' => Role::BRIGADIER]); } // ==================== Authentication ==================== public function test_guest_cannot_access_reclamations_index(): void { $response = $this->get(route('reclamations.index')); $response->assertRedirect(route('login')); } public function test_authenticated_user_can_access_reclamations_index(): void { $response = $this->actingAs($this->managerUser) ->get(route('reclamations.index')); $response->assertStatus(200); $response->assertViewIs('reclamations.index'); } // ==================== Index ==================== public function test_reclamations_index_displays_reclamations(): void { $reclamation = Reclamation::factory()->create(); $response = $this->actingAs($this->managerUser) ->get(route('reclamations.index')); $response->assertStatus(200); } public function test_brigadier_sees_only_assigned_reclamations_with_allowed_statuses(): void { $visibleReclamation = Reclamation::factory()->create([ 'brigadier_id' => $this->brigadierUser->id, 'status_id' => Reclamation::STATUS_IN_WORK, 'reason' => 'Видимая рекламация', ]); $hiddenByStatus = Reclamation::factory()->create([ 'brigadier_id' => $this->brigadierUser->id, 'status_id' => Reclamation::STATUS_DONE, 'reason' => 'Скрытая по статусу', ]); $hiddenByBrigadier = Reclamation::factory()->create([ 'brigadier_id' => User::factory()->create(['role' => Role::BRIGADIER])->id, 'status_id' => Reclamation::STATUS_IN_WORK, 'reason' => 'Скрытая по бригадиру', ]); $response = $this->actingAs($this->brigadierUser) ->get(route('reclamations.index')); $response->assertStatus(200); $response->assertSee($visibleReclamation->reason); $response->assertDontSee($hiddenByStatus->reason); $response->assertDontSee($hiddenByBrigadier->reason); } // ==================== Create ==================== public function test_can_create_reclamation_for_order(): void { $order = Order::factory()->create(); $product = Product::factory()->create(); $productSku = ProductSKU::factory()->create([ 'order_id' => $order->id, 'product_id' => $product->id, ]); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.create', $order), [ 'skus' => [$productSku->id], ]); $response->assertRedirect(); $this->assertDatabaseHas('reclamations', [ 'order_id' => $order->id, 'user_id' => $this->managerUser->id, 'status_id' => Reclamation::STATUS_NEW, ]); } public function test_creating_reclamation_from_order_preserves_nav_token(): void { $order = Order::factory()->create(); $product = Product::factory()->create(); $productSku = ProductSKU::factory()->create([ 'order_id' => $order->id, 'product_id' => $product->id, ]); $response = $this->actingAs($this->managerUser) ->withSession([ 'navigation' => [ 'order-nav-token' => [ 'updated_at' => time(), 'stack' => [ route('order.index'), route('order.show', $order), ], ], ], ]) ->post(route('reclamations.create', ['order' => $order, 'nav' => 'order-nav-token']), [ 'skus' => [$productSku->id], 'nav' => 'order-nav-token', ]); $location = $response->headers->get('Location'); $this->assertNotNull($location); $this->assertStringContainsString('nav=order-nav-token', $location); } public function test_creating_reclamation_attaches_skus(): void { $order = Order::factory()->create(); $product = Product::factory()->create(); $productSku1 = ProductSKU::factory()->create([ 'order_id' => $order->id, 'product_id' => $product->id, ]); $productSku2 = ProductSKU::factory()->create([ 'order_id' => $order->id, 'product_id' => $product->id, ]); $this->actingAs($this->managerUser) ->post(route('reclamations.create', $order), [ 'skus' => [$productSku1->id, $productSku2->id], ]); $reclamation = Reclamation::where('order_id', $order->id)->first(); $this->assertCount(2, $reclamation->skus); } // ==================== Show ==================== public function test_can_view_reclamation_details(): void { $reclamation = Reclamation::factory()->create(); $response = $this->actingAs($this->managerUser) ->get(route('reclamations.show', $reclamation)); $response->assertStatus(200); $response->assertViewIs('reclamations.edit'); } public function test_reclamation_show_uses_nav_context_for_back_url(): void { $reclamation = Reclamation::factory()->create(); $indexResponse = $this->actingAs($this->managerUser) ->get(route('reclamations.index', [ 'filters' => ['comment' => 'КС готова'], ])); $nav = $indexResponse->viewData('nav'); $response = $this->actingAs($this->managerUser) ->get(route('reclamations.show', [ 'reclamation' => $reclamation, 'nav' => $nav, ])); $response->assertOk(); $response->assertViewHas('nav', $nav); $response->assertViewHas('back_url', function (string $backUrl): bool { if (!str_starts_with($backUrl, route('reclamations.index'))) { return false; } $query = parse_url($backUrl, PHP_URL_QUERY); parse_str((string) $query, $params); return ($params['filters']['comment'] ?? null) === 'КС готова'; }); } public function test_reclamations_index_starts_new_navigation_context(): void { $response = $this->actingAs($this->managerUser) ->withSession([ 'navigation' => [ 'existing-card-nav' => [ 'updated_at' => time(), 'stack' => [ route('order.show', Order::factory()->create()), route('reclamations.show', Reclamation::factory()->create()), ], ], ], ]) ->get(route('reclamations.index', ['nav' => 'existing-card-nav'])); $response->assertOk(); $response->assertViewHas('nav', function (string $nav): bool { return $nav !== 'existing-card-nav'; }); } public function test_reclamation_show_returns_to_order_after_coming_back_from_catalog(): void { $order = Order::factory()->create(); $reclamation = Reclamation::factory()->create([ 'order_id' => $order->id, 'user_id' => $this->managerUser->id, ]); $response = $this->actingAs($this->managerUser) ->withSession([ 'navigation' => [ 'order-reclamation-nav' => [ 'updated_at' => time(), 'stack' => [ route('order.show', $order), route('reclamations.show', $reclamation), route('catalog.show', Product::factory()->create()), ], ], ], ]) ->get(route('reclamations.show', [ 'reclamation' => $reclamation, 'nav' => 'order-reclamation-nav', ])); $response->assertOk(); $response->assertViewHas('back_url', route('order.show', [ 'order' => $order, 'nav' => 'order-reclamation-nav', ])); } public function test_existing_reclamation_opened_from_order_keeps_order_as_back_target_after_catalog(): void { $order = Order::factory()->create([ 'object_address' => 'ул. Навигационная, д. 7', ]); $product = Product::factory()->create(); $productSku = ProductSKU::factory()->create([ 'order_id' => $order->id, 'product_id' => $product->id, ]); $reclamation = Reclamation::factory()->create([ 'order_id' => $order->id, 'user_id' => $this->managerUser->id, ]); $reclamation->skus()->attach($productSku->id); $indexResponse = $this->actingAs($this->managerUser) ->get(route('order.index')); $nav = $indexResponse->viewData('nav'); $orderResponse = $this->actingAs($this->managerUser) ->get(route('order.show', ['order' => $order, 'nav' => $nav])); $orderResponse->assertOk(); $reclamationResponse = $this->actingAs($this->managerUser) ->get(route('reclamations.show', ['reclamation' => $reclamation, 'nav' => $nav])); $reclamationResponse->assertOk(); $reclamationResponse->assertViewHas('back_url', route('order.show', [ 'order' => $order, 'nav' => $nav, ])); $catalogResponse = $this->actingAs($this->managerUser) ->get(route('catalog.show', ['product' => $product, 'nav' => $nav])); $catalogResponse->assertOk(); $reclamationBackResponse = $this->actingAs($this->managerUser) ->get(route('reclamations.show', ['reclamation' => $reclamation, 'nav' => $nav])); $reclamationBackResponse->assertOk(); $reclamationBackResponse->assertViewHas('back_url', route('order.show', [ 'order' => $order, 'nav' => $nav, ])); } public function test_reclamation_details_show_spare_part_note_in_input_instead_of_used_in_maf(): void { $sparePart = \App\Models\SparePart::factory()->create([ 'article' => 'SP-100', 'used_in_maf' => 'Старое значение', 'note' => 'Показать это примечание', ]); $reclamation = Reclamation::factory()->create(); $reclamation->spareParts()->attach($sparePart->id, [ 'quantity' => 1, 'with_documents' => false, 'status' => 'pending', 'reserved_qty' => 0, 'issued_qty' => 0, ]); $response = $this->actingAs($this->managerUser) ->get(route('reclamations.show', $reclamation)); $response->assertOk(); $response->assertSee('SP-100 (Показать это примечание)'); $response->assertDontSee('SP-100 (Старое значение)'); } public function test_brigadier_cannot_view_reclamation_details_with_hidden_status(): void { $reclamation = Reclamation::factory()->create([ 'brigadier_id' => $this->brigadierUser->id, 'status_id' => Reclamation::STATUS_DONE, ]); $response = $this->actingAs($this->brigadierUser) ->get(route('reclamations.show', $reclamation)); $response->assertStatus(403); } // ==================== Update ==================== public function test_can_update_reclamation(): void { $reclamation = Reclamation::factory()->create([ 'reason' => 'Старая причина', ]); // Route uses POST, not PUT. All required fields must be sent. $response = $this->actingAs($this->managerUser) ->post(route('reclamations.update', $reclamation), [ 'user_id' => $reclamation->user_id, 'status_id' => $reclamation->status_id, 'create_date' => $reclamation->create_date, 'finish_date' => $reclamation->finish_date, 'reason' => 'Новая причина', 'guarantee' => 'Гарантия', 'whats_done' => 'Что сделано', ]); $location = $response->headers->get('Location'); $this->assertNotNull($location); $this->assertStringContainsString('/reclamations/show/' . $reclamation->id, $location); $this->assertStringContainsString('nav=', $location); $this->assertDatabaseHas('reclamations', [ 'id' => $reclamation->id, 'reason' => 'Новая причина', ]); } public function test_update_redirects_with_nav_token(): void { $reclamation = Reclamation::factory()->create([ 'reason' => 'Старая причина', ]); $nav = 'nav-test-token'; $response = $this->actingAs($this->managerUser) ->withSession([ 'navigation' => [ $nav => [ 'updated_at' => time(), 'stack' => [ route('reclamations.index'), route('reclamations.show', $reclamation), ], ], ], ]) ->post(route('reclamations.update', $reclamation), [ 'nav' => $nav, 'user_id' => $reclamation->user_id, 'status_id' => $reclamation->status_id, 'create_date' => $reclamation->create_date, 'finish_date' => $reclamation->finish_date, 'reason' => 'Новая причина', 'guarantee' => 'Гарантия', 'whats_done' => 'Что сделано', ]); $response->assertRedirect(route('reclamations.show', [ 'reclamation' => $reclamation, 'nav' => $nav, ])); } public function test_ajax_update_returns_no_content_without_redirect_location(): void { $reclamation = Reclamation::factory()->create([ 'reason' => 'Старая причина', ]); $response = $this->actingAs($this->managerUser) ->withHeader('X-Requested-With', 'XMLHttpRequest') ->post(route('reclamations.update', $reclamation), [ 'nav' => 'ajax-nav-token', 'user_id' => $reclamation->user_id, 'status_id' => $reclamation->status_id, 'create_date' => $reclamation->create_date, 'finish_date' => $reclamation->finish_date, 'reason' => 'Новая причина', 'guarantee' => 'Гарантия', 'whats_done' => 'Что сделано', ]); $response->assertNoContent(); $this->assertNull($response->headers->get('Location')); } // ==================== Delete ==================== public function test_can_delete_reclamation(): void { $reclamation = Reclamation::factory()->create(); $reclamationId = $reclamation->id; $response = $this->actingAs($this->adminUser) ->delete(route('reclamations.delete', $reclamation)); $response->assertRedirect(route('reclamations.index')); $this->assertDatabaseMissing('reclamations', ['id' => $reclamationId]); } // ==================== Photo Before Management ==================== public function test_can_upload_photo_before(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); // Use create() instead of image() to avoid GD extension requirement $photo = UploadedFile::fake()->create('photo_before.jpg', 100, 'image/jpeg'); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-photo-before', $reclamation), [ 'photo' => [$photo], ]); $response->assertRedirect(); $this->assertCount(1, $reclamation->fresh()->photos_before); } public function test_can_upload_photo_before_in_webp_format(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $photo = UploadedFile::fake()->create('photo_before.webp', 100, 'image/webp'); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-photo-before', $reclamation), [ 'photo' => [$photo], ]); $response->assertRedirect(); $saved = $reclamation->fresh()->photos_before->first(); $this->assertNotNull($saved); $this->assertSame('photo_before.webp', $saved->original_name); } public function test_upload_photo_before_preserves_unicode_and_quotes_filename(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $filename = "Фото «до» 'левая' \"камера\".jpg"; $photo = UploadedFile::fake()->create($filename, 100, 'image/jpeg'); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-photo-before', $reclamation), [ 'photo' => [$photo], ]); $response->assertRedirect(); $saved = $reclamation->fresh()->photos_before->first(); $this->assertNotNull($saved); $this->assertEquals($filename, $saved->original_name); $this->assertEquals('reclamations/' . $reclamation->id . '/photo_before/' . $filename, $saved->path); Storage::disk('public')->assertExists($saved->path); } public function test_can_delete_photo_before(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $file = File::factory()->create(); $reclamation->photos_before()->attach($file); $response = $this->actingAs($this->adminUser) ->delete(route('reclamations.delete-photo-before', [$reclamation, $file])); $response->assertRedirect(); $this->assertCount(0, $reclamation->fresh()->photos_before); } // ==================== Photo After Management ==================== public function test_can_upload_photo_after(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); // Use create() instead of image() to avoid GD extension requirement $photo = UploadedFile::fake()->create('photo_after.jpg', 100, 'image/jpeg'); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-photo-after', $reclamation), [ 'photo' => [$photo], ]); $response->assertRedirect(); $this->assertCount(1, $reclamation->fresh()->photos_after); } public function test_can_upload_photo_after_in_webp_format(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $photo = UploadedFile::fake()->create('photo_after.webp', 100, 'image/webp'); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-photo-after', $reclamation), [ 'photo' => [$photo], ]); $response->assertRedirect(); $saved = $reclamation->fresh()->photos_after->first(); $this->assertNotNull($saved); $this->assertSame('photo_after.webp', $saved->original_name); } public function test_can_delete_photo_after(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $file = File::factory()->create(); $reclamation->photos_after()->attach($file); // This route requires admin role $response = $this->actingAs($this->adminUser) ->delete(route('reclamations.delete-photo-after', [$reclamation, $file])); $response->assertRedirect(); $this->assertCount(0, $reclamation->fresh()->photos_after); } // ==================== Document Management ==================== public function test_can_upload_document(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $document = UploadedFile::fake()->create('document.pdf', 100); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-document', $reclamation), [ 'document' => [$document], ]); $response->assertRedirect(); $this->assertCount(1, $reclamation->fresh()->documents); } public function test_upload_document_preserves_unicode_and_quotes_filename(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $filename = "Рекламация «док» 'версия' \"A\".pdf"; $document = UploadedFile::fake()->create($filename, 100, 'application/pdf'); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-document', $reclamation), [ 'document' => [$document], ]); $response->assertRedirect(); $saved = $reclamation->fresh()->documents->first(); $this->assertNotNull($saved); $this->assertEquals($filename, $saved->original_name); $this->assertEquals('reclamations/' . $reclamation->id . '/document/' . $filename, $saved->path); Storage::disk('public')->assertExists($saved->path); } public function test_can_delete_document(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $file = File::factory()->create(); $reclamation->documents()->attach($file); // This route requires admin role $response = $this->actingAs($this->adminUser) ->delete(route('reclamations.delete-document', [$reclamation, $file])); $response->assertRedirect(); $this->assertCount(0, $reclamation->fresh()->documents); } // ==================== Act Management ==================== public function test_can_upload_act(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $act = UploadedFile::fake()->create('act.pdf', 100); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-act', $reclamation), [ 'acts' => [$act], ]); $response->assertRedirect(); $this->assertCount(1, $reclamation->fresh()->acts); } public function test_upload_act_preserves_unicode_and_quotes_filename(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $filename = "Акт «сервис» 'этап' \"01\".pdf"; $act = UploadedFile::fake()->create($filename, 100, 'application/pdf'); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.upload-act', $reclamation), [ 'acts' => [$act], ]); $response->assertRedirect(); $saved = $reclamation->fresh()->acts->first(); $this->assertNotNull($saved); $this->assertEquals($filename, $saved->original_name); $this->assertEquals('reclamations/' . $reclamation->id . '/act/' . $filename, $saved->path); Storage::disk('public')->assertExists($saved->path); } public function test_can_delete_act(): void { Storage::fake('public'); $reclamation = Reclamation::factory()->create(); $file = File::factory()->create(); $reclamation->acts()->attach($file); // This route requires admin role $response = $this->actingAs($this->adminUser) ->delete(route('reclamations.delete-act', [$reclamation, $file])); $response->assertRedirect(); $this->assertCount(0, $reclamation->fresh()->acts); } // ==================== Spare Parts Reservation ==================== public function test_update_spare_parts_creates_reservations(): void { $reclamation = Reclamation::factory()->create(); $sparePart = SparePart::factory()->create(); // Create available stock SparePartOrder::factory() ->inStock() ->withDocuments(false) ->withQuantity(10) ->forSparePart($sparePart) ->create(); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.update-spare-parts', $reclamation), [ 'rows' => [ [ 'spare_part_id' => $sparePart->id, 'quantity' => 3, 'with_documents' => false, ], ], ]); $response->assertRedirect(); // Check spare part is attached $this->assertTrue($reclamation->fresh()->spareParts->contains($sparePart->id)); // Check reservation was created $this->assertDatabaseHas('reservations', [ 'reclamation_id' => $reclamation->id, 'spare_part_id' => $sparePart->id, 'reserved_qty' => 3, 'status' => Reservation::STATUS_ACTIVE, ]); } public function test_update_spare_parts_cancels_removed_reservations(): void { $reclamation = Reclamation::factory()->create(); $sparePart = SparePart::factory()->create(); $order = SparePartOrder::factory() ->inStock() ->withDocuments(false) ->withQuantity(10) ->forSparePart($sparePart) ->create(); // Create existing reservation Reservation::factory() ->active() ->withQuantity(5) ->withDocuments(false) ->fromOrder($order) ->forReclamation($reclamation) ->create(); // Attach spare part $reclamation->spareParts()->attach($sparePart->id, [ 'quantity' => 5, 'with_documents' => false, 'reserved_qty' => 5, ]); // Send empty rows to remove spare part $response = $this->actingAs($this->managerUser) ->post(route('reclamations.update-spare-parts', $reclamation), [ 'rows' => [], ]); $response->assertRedirect(); // Check spare part is detached $this->assertFalse($reclamation->fresh()->spareParts->contains($sparePart->id)); // Check reservation was cancelled $this->assertDatabaseHas('reservations', [ 'reclamation_id' => $reclamation->id, 'spare_part_id' => $sparePart->id, 'status' => Reservation::STATUS_CANCELLED, ]); } // ==================== Details Management ==================== public function test_update_details_creates_reclamation_detail(): void { $reclamation = Reclamation::factory()->create(); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.update-details', $reclamation), [ 'name' => ['Деталь 1', 'Деталь 2'], 'quantity' => ['2', '3'], // Controller casts to int, send as strings like form data ]); $response->assertRedirect(); $response->assertSessionHasNoErrors(); $this->assertDatabaseHas('reclamation_details', [ 'reclamation_id' => $reclamation->id, 'name' => 'Деталь 1', 'quantity' => 2, ]); $this->assertDatabaseHas('reclamation_details', [ 'reclamation_id' => $reclamation->id, 'name' => 'Деталь 2', 'quantity' => 3, ]); } public function test_update_details_removes_detail_with_zero_quantity(): void { $reclamation = Reclamation::factory()->create(); ReclamationDetail::create([ 'reclamation_id' => $reclamation->id, 'name' => 'Деталь для удаления', 'quantity' => 5, ]); $response = $this->actingAs($this->managerUser) ->post(route('reclamations.update-details', $reclamation), [ 'name' => ['Деталь для удаления'], 'quantity' => ['0'], // Send as string like form data ]); $response->assertRedirect(); $this->assertDatabaseMissing('reclamation_details', [ 'reclamation_id' => $reclamation->id, 'name' => 'Деталь для удаления', ]); } // ==================== Generation ==================== public function test_can_generate_reclamation_pack(): void { $reclamation = Reclamation::factory()->create(); $response = $this->actingAs($this->managerUser) ->get(route('order.generate-reclamation-pack', $reclamation)); $response->assertRedirect(); $response->assertSessionHas('success'); } public function test_can_generate_photos_before_pack(): void { $reclamation = Reclamation::factory()->create(); $response = $this->actingAs($this->managerUser) ->get(route('reclamation.generate-photos-before-pack', $reclamation)); $response->assertRedirect(); $response->assertSessionHas('success'); } public function test_can_generate_photos_after_pack(): void { $reclamation = Reclamation::factory()->create(); $response = $this->actingAs($this->managerUser) ->get(route('reclamation.generate-photos-after-pack', $reclamation)); $response->assertRedirect(); $response->assertSessionHas('success'); } }