admin = User::factory()->create(['role' => Role::ADMIN]); $this->manager = User::factory()->create(['role' => Role::MANAGER]); $this->assistantHead = User::factory()->create(['role' => Role::ASSISTANT_HEAD]); } public function test_guest_cannot_access_common_catalog(): void { $this->get(route('common-catalog.index'))->assertRedirect(route('login')); } public function test_standard_authenticated_roles_can_view_year_independent_catalog(): void { $item = CommonCatalogItem::factory()->create([ 'article' => '0254', 'calculator_name' => 'Оборудование для благоустройства', ]); $this->actingAs($this->manager) ->withSession(['year' => 2021]) ->get(route('common-catalog.index')) ->assertOk() ->assertViewIs('common_catalog.index') ->assertSee($item->article) ->assertSee($item->calculator_name); } public function test_common_catalog_column_filter_values_are_available(): void { CommonCatalogItem::factory()->create([ 'article' => 'FILTER-001', 'builders_price' => 123456.78, ]); $deletedItem = CommonCatalogItem::factory()->create(['article' => 'FILTER-DELETED']); $deletedItem->delete(); $this->actingAs($this->admin) ->getJson(route('getFilters', [ 'table' => 'common_catalog_items', 'column' => 'article', ])) ->assertOk() ->assertJsonFragment(['FILTER-001']) ->assertJsonMissing(['FILTER-DELETED']); $this->actingAs($this->admin) ->getJson(route('getFilters', [ 'table' => 'common_catalog_items', 'column' => 'builders_price_txt', ])) ->assertOk() ->assertJsonFragment([123456.78]); } public function test_common_catalog_column_filter_is_applied(): void { CommonCatalogItem::factory()->create([ 'article' => 'FILTER-MATCH', 'calculator_name' => 'Нужная позиция', 'builders_price' => 123456.78, ]); CommonCatalogItem::factory()->create([ 'article' => 'FILTER-OTHER', 'calculator_name' => 'Другая позиция', 'builders_price' => 987654.32, ]); $this->actingAs($this->admin) ->get(route('common-catalog.index', [ 'filters' => ['article' => 'FILTER-MATCH'], ])) ->assertOk() ->assertSee('Нужная позиция') ->assertDontSee('Другая позиция'); $this->actingAs($this->admin) ->get(route('common-catalog.index', [ 'filters' => ['builders_price_txt' => '123456.78'], ])) ->assertOk() ->assertSee('Нужная позиция') ->assertDontSee('Другая позиция'); } public function test_common_catalog_filter_values_respect_field_access(): void { CommonCatalogItem::factory()->create(['builders_price' => 123456.78]); $this->actingAs($this->manager) ->getJson(route('getFilters', [ 'table' => 'common_catalog_items', 'column' => 'builders_price_txt', ])) ->assertForbidden(); } public function test_manager_cannot_mutate_common_catalog(): void { $this->actingAs($this->manager) ->post(route('common-catalog.store'), $this->validData()) ->assertForbidden(); } public function test_prices_are_visible_to_admin_and_hidden_from_other_roles_by_default(): void { $item = CommonCatalogItem::factory()->create([ 'article' => 'PRICE-001', 'builders_price' => 123456.78, ]); $this->actingAs($this->admin) ->get(route('common-catalog.show', $item)) ->assertOk() ->assertSeeText('Цены') ->assertSeeText('строители') ->assertSee('123456.78', false); $this->actingAs($this->manager) ->get(route('common-catalog.show', $item)) ->assertOk() ->assertDontSeeText('Цены') ->assertDontSeeText('строители') ->assertDontSee('123456.78', false); $this->actingAs($this->assistantHead) ->get(route('common-catalog.show', $item)) ->assertOk() ->assertDontSeeText('Цены') ->assertDontSeeText('строители'); } public function test_assistant_cannot_change_hidden_price_with_forged_payload(): void { $item = CommonCatalogItem::factory()->create([ 'calculator_name' => 'Старое наименование', 'builders_price' => 100, ]); $payload = $this->validData(); $payload['article'] = $item->article; $payload['calculator_name'] = 'Новое наименование'; $payload['builders_price'] = 999999; $this->actingAs($this->assistantHead) ->put(route('common-catalog.update', $item), $payload) ->assertRedirect(); $item->refresh(); $this->assertSame('Новое наименование', $item->calculator_name); $this->assertSame(100.0, $item->builders_price); } public function test_export_is_available_only_to_admin_by_default(): void { Bus::fake([ExportCommonCatalogJob::class]); $this->actingAs($this->assistantHead) ->post(route('common-catalog.export')) ->assertForbidden(); Bus::assertNotDispatched(ExportCommonCatalogJob::class); $this->actingAs($this->admin) ->post(route('common-catalog.export')) ->assertRedirect(route('common-catalog.index')); Bus::assertDispatched(ExportCommonCatalogJob::class); } public function test_admin_can_create_update_and_delete_item(): void { $this->actingAs($this->admin) ->post(route('common-catalog.store'), $this->validData()) ->assertRedirect(); $item = CommonCatalogItem::query()->where('article', '0007')->firstOrFail(); $updated = $this->validData(); $updated['calculator_name'] = 'Обновлённое наименование'; $this->actingAs($this->admin) ->put(route('common-catalog.update', $item), $updated) ->assertRedirect(); $this->assertDatabaseHas('common_catalog_items', [ 'id' => $item->id, 'article' => '0007', 'calculator_name' => 'Обновлённое наименование', ]); $this->actingAs($this->admin) ->delete(route('common-catalog.destroy', $item)) ->assertRedirect(route('common-catalog.index')); $this->assertSoftDeleted('common_catalog_items', ['id' => $item->id]); } public function test_article_and_name_are_required_and_their_pair_is_unique(): void { CommonCatalogItem::factory()->create([ 'article' => '0007', 'calculator_name' => 'Игровой комплекс', ]); $this->actingAs($this->admin) ->post(route('common-catalog.store'), [ 'article' => '', 'calculator_name' => '', ]) ->assertSessionHasErrors(['article', 'calculator_name']); $this->actingAs($this->admin) ->post(route('common-catalog.store'), [ 'article' => '0007', 'calculator_name' => 'Игровой комплекс', ]) ->assertSessionHasErrors(['article']); } public function test_same_article_is_allowed_for_different_named_variants(): void { CommonCatalogItem::factory()->create([ 'article' => '6416', 'calculator_name' => 'Оборудование игровой площадки', ]); $payload = $this->validData(); $payload['article'] = '6416'; $payload['calculator_name'] = 'Оборудование игровой площадки (Цинк)'; $this->actingAs($this->admin) ->post(route('common-catalog.store'), $payload) ->assertRedirect(); $this->assertDatabaseCount('common_catalog_items', 2); } public function test_item_card_contains_technical_description_and_calculation_placeholders(): void { $item = CommonCatalogItem::factory()->create(); $this->actingAs($this->admin) ->get(route('common-catalog.show', $item)) ->assertOk() ->assertSeeText('Техническое описание') ->assertSeeText('Калькуляция'); } public function test_technical_description_price_uses_a_non_breaking_space_instead_of_an_html_entity(): void { $item = CommonCatalogItem::factory()->create([ 'project_price' => 434800, ]); $this->actingAs($this->admin) ->get(route('common-catalog.show', $item)) ->assertOk() ->assertSee("434\u{00A0}800.00₽", false) ->assertDontSee(' ', false); } public function test_admin_can_edit_technical_description_fields(): void { $item = CommonCatalogItem::factory()->create(); $payload = $this->validData(); $payload['article'] = $item->article; $payload['print_name'] = 'Наименование для формы'; $payload['product_group'] = 'Игровое оборудование'; $payload['characteristics'] = 'Характеристики'; $payload['technical_description'] = 'Полное описание'; $payload['technical_description_short'] = 'Краткое описание'; $this->actingAs($this->admin) ->put(route('common-catalog.update', $item), $payload) ->assertRedirect(); $this->assertDatabaseHas('common_catalog_items', [ 'id' => $item->id, 'print_name' => 'Наименование для формы', 'technical_description' => 'Полное описание', ]); } public function test_technical_description_export_requires_access_to_selected_price(): void { Bus::fake([ExportTechnicalDescriptionsJob::class]); $item = CommonCatalogItem::factory()->create(); $payload = [ 'item_ids' => [$item->id], 'description_mode' => \App\Services\TechnicalDescriptionService::MODE_CHARACTERISTICS_SHORT, ]; $this->actingAs($this->manager) ->post(route('common-catalog.technical-description.export'), $payload) ->assertForbidden(); Bus::assertNotDispatched(ExportTechnicalDescriptionsJob::class); $this->actingAs($this->admin) ->post(route('common-catalog.technical-description.export'), $payload) ->assertRedirect(); Bus::assertDispatched(ExportTechnicalDescriptionsJob::class); } public function test_generated_technical_description_is_private_to_owner_or_admin(): void { Storage::fake('local'); $owner = User::factory()->create(['role' => Role::MANAGER]); $otherManager = User::factory()->create(['role' => Role::MANAGER]); $path = 'generated/technical-descriptions/'.$owner->id.'/test/document.docx'; Storage::disk('local')->put($path, 'docx'); $file = File::query()->create([ 'user_id' => $owner->id, 'path' => $path, 'link' => '', 'original_name' => 'document.docx', 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'is_generated' => true, ]); $this->actingAs($otherManager) ->get(route('common-catalog.technical-description.download', $file)) ->assertForbidden(); $this->actingAs($owner) ->get(route('common-catalog.technical-description.download', $file)) ->assertDownload('document.docx'); $this->actingAs($this->admin) ->get(route('common-catalog.technical-description.download', $file)) ->assertDownload('document.docx'); } public function test_admin_can_upload_and_delete_image(): void { Storage::fake('public'); $item = CommonCatalogItem::factory()->create(); $this->actingAs($this->admin) ->post(route('common-catalog.image.upload', $item), [ 'image' => UploadedFile::fake()->image('item.png', 200, 200), ]) ->assertRedirect(); $item->refresh(); $this->assertNotNull($item->imageFile); Storage::disk('public')->assertExists($item->imageFile->path); $fileId = $item->imageFile->id; $this->actingAs($this->admin) ->delete(route('common-catalog.image.delete', $item)) ->assertRedirect(); $this->assertDatabaseMissing('files', ['id' => $fileId]); $this->assertNull($item->refresh()->image_file_id); } public function test_admin_can_upload_and_delete_multiple_documents(): void { Storage::fake('public'); $item = CommonCatalogItem::factory()->create(); foreach (['certificate.pdf', 'manual.pdf'] as $filename) { $this->actingAs($this->admin) ->post(route('common-catalog.documents.upload', $item), [ 'document' => UploadedFile::fake()->create($filename, 20, 'application/pdf'), ]) ->assertRedirect(); } $this->assertCount(2, $item->refresh()->documents); $document = $item->documents->first(); $this->actingAs($this->admin) ->delete(route('common-catalog.documents.delete', [$item, $document])) ->assertRedirect(); $this->assertCount(1, $item->refresh()->documents); $this->assertDatabaseMissing('files', ['id' => $document->id]); } public function test_import_and_export_are_queued(): void { Storage::fake('upload'); Bus::fake([ImportJob::class, ExportCommonCatalogJob::class]); $this->actingAs($this->admin) ->post(route('common-catalog.import'), [ 'import_file' => UploadedFile::fake()->create( 'common-catalog.xlsx', 20, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ), ]) ->assertRedirect(route('common-catalog.index')); $this->assertDatabaseHas('imports', [ 'type' => 'common_catalog', 'year' => null, 'user_id' => $this->admin->id, ]); Bus::assertDispatched(ImportJob::class); $this->actingAs($this->admin) ->post(route('common-catalog.export')) ->assertRedirect(route('common-catalog.index')); Bus::assertDispatched(ExportCommonCatalogJob::class); } private function validData(): array { return [ 'article' => '0007', 'calculator_name' => "0007\nИгровой комплекс", 'kind' => 'Игровое оборудование', 'dimension_length' => '2', 'dimension_width' => '3', 'dimension_height' => '1,5', 'site_length' => '5', 'site_width' => '6', 'fall_height' => 1.2, 'dimension_unit' => 'м', 'weight' => 350.5, 'volume' => 4.25, 'places' => 3, 'age_group' => '3+', 'max_users' => 8, 'unit' => 'шт.', 'series' => 'Двор', 'trademark' => 'Наш Двор', 'calculator_enabled' => true, 'builders_price' => 100000, 'wholesale_price' => 105000, 'recommended_price' => 110000, 'retail_price' => 150000, 'project_price' => 200000, 'project_with_installation_price' => 240000, 'pik_price' => 110000, 'recommended_plus_10_price' => 121000, ]; } }