CommonCatalogControllerTest.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. <?php
  2. declare(strict_types=1);
  3. namespace Tests\Feature;
  4. use App\Jobs\Export\ExportCommonCatalogJob;
  5. use App\Jobs\Export\ExportTechnicalDescriptionsJob;
  6. use App\Jobs\Import\ImportJob;
  7. use App\Models\CommonCatalogItem;
  8. use App\Models\File;
  9. use App\Models\Role;
  10. use App\Models\User;
  11. use Illuminate\Foundation\Testing\RefreshDatabase;
  12. use Illuminate\Http\UploadedFile;
  13. use Illuminate\Support\Facades\Bus;
  14. use Illuminate\Support\Facades\Storage;
  15. use Tests\TestCase;
  16. class CommonCatalogControllerTest extends TestCase
  17. {
  18. use RefreshDatabase;
  19. protected bool $seed = true;
  20. private User $admin;
  21. private User $manager;
  22. private User $assistantHead;
  23. protected function setUp(): void
  24. {
  25. parent::setUp();
  26. $this->admin = User::factory()->create(['role' => Role::ADMIN]);
  27. $this->manager = User::factory()->create(['role' => Role::MANAGER]);
  28. $this->assistantHead = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
  29. }
  30. public function test_guest_cannot_access_common_catalog(): void
  31. {
  32. $this->get(route('common-catalog.index'))->assertRedirect(route('login'));
  33. }
  34. public function test_standard_authenticated_roles_can_view_year_independent_catalog(): void
  35. {
  36. $item = CommonCatalogItem::factory()->create([
  37. 'article' => '0254',
  38. 'calculator_name' => 'Оборудование для благоустройства',
  39. ]);
  40. $this->actingAs($this->manager)
  41. ->withSession(['year' => 2021])
  42. ->get(route('common-catalog.index'))
  43. ->assertOk()
  44. ->assertViewIs('common_catalog.index')
  45. ->assertSee($item->article)
  46. ->assertSee($item->calculator_name);
  47. }
  48. public function test_common_catalog_column_filter_values_are_available(): void
  49. {
  50. CommonCatalogItem::factory()->create([
  51. 'article' => 'FILTER-001',
  52. 'builders_price' => 123456.78,
  53. ]);
  54. $deletedItem = CommonCatalogItem::factory()->create(['article' => 'FILTER-DELETED']);
  55. $deletedItem->delete();
  56. $this->actingAs($this->admin)
  57. ->getJson(route('getFilters', [
  58. 'table' => 'common_catalog_items',
  59. 'column' => 'article',
  60. ]))
  61. ->assertOk()
  62. ->assertJsonFragment(['FILTER-001'])
  63. ->assertJsonMissing(['FILTER-DELETED']);
  64. $this->actingAs($this->admin)
  65. ->getJson(route('getFilters', [
  66. 'table' => 'common_catalog_items',
  67. 'column' => 'builders_price_txt',
  68. ]))
  69. ->assertOk()
  70. ->assertJsonFragment([123456.78]);
  71. }
  72. public function test_common_catalog_column_filter_is_applied(): void
  73. {
  74. CommonCatalogItem::factory()->create([
  75. 'article' => 'FILTER-MATCH',
  76. 'calculator_name' => 'Нужная позиция',
  77. 'builders_price' => 123456.78,
  78. ]);
  79. CommonCatalogItem::factory()->create([
  80. 'article' => 'FILTER-OTHER',
  81. 'calculator_name' => 'Другая позиция',
  82. 'builders_price' => 987654.32,
  83. ]);
  84. $this->actingAs($this->admin)
  85. ->get(route('common-catalog.index', [
  86. 'filters' => ['article' => 'FILTER-MATCH'],
  87. ]))
  88. ->assertOk()
  89. ->assertSee('Нужная позиция')
  90. ->assertDontSee('Другая позиция');
  91. $this->actingAs($this->admin)
  92. ->get(route('common-catalog.index', [
  93. 'filters' => ['builders_price_txt' => '123456.78'],
  94. ]))
  95. ->assertOk()
  96. ->assertSee('Нужная позиция')
  97. ->assertDontSee('Другая позиция');
  98. }
  99. public function test_common_catalog_filter_values_respect_field_access(): void
  100. {
  101. CommonCatalogItem::factory()->create(['builders_price' => 123456.78]);
  102. $this->actingAs($this->manager)
  103. ->getJson(route('getFilters', [
  104. 'table' => 'common_catalog_items',
  105. 'column' => 'builders_price_txt',
  106. ]))
  107. ->assertForbidden();
  108. }
  109. public function test_manager_cannot_mutate_common_catalog(): void
  110. {
  111. $this->actingAs($this->manager)
  112. ->post(route('common-catalog.store'), $this->validData())
  113. ->assertForbidden();
  114. }
  115. public function test_prices_are_visible_to_admin_and_hidden_from_other_roles_by_default(): void
  116. {
  117. $item = CommonCatalogItem::factory()->create([
  118. 'article' => 'PRICE-001',
  119. 'builders_price' => 123456.78,
  120. ]);
  121. $this->actingAs($this->admin)
  122. ->get(route('common-catalog.show', $item))
  123. ->assertOk()
  124. ->assertSeeText('Цены')
  125. ->assertSeeText('строители')
  126. ->assertSee('123456.78', false);
  127. $this->actingAs($this->manager)
  128. ->get(route('common-catalog.show', $item))
  129. ->assertOk()
  130. ->assertDontSeeText('Цены')
  131. ->assertDontSeeText('строители')
  132. ->assertDontSee('123456.78', false);
  133. $this->actingAs($this->assistantHead)
  134. ->get(route('common-catalog.show', $item))
  135. ->assertOk()
  136. ->assertDontSeeText('Цены')
  137. ->assertDontSeeText('строители');
  138. }
  139. public function test_assistant_cannot_change_hidden_price_with_forged_payload(): void
  140. {
  141. $item = CommonCatalogItem::factory()->create([
  142. 'calculator_name' => 'Старое наименование',
  143. 'builders_price' => 100,
  144. ]);
  145. $payload = $this->validData();
  146. $payload['article'] = $item->article;
  147. $payload['calculator_name'] = 'Новое наименование';
  148. $payload['builders_price'] = 999999;
  149. $this->actingAs($this->assistantHead)
  150. ->put(route('common-catalog.update', $item), $payload)
  151. ->assertRedirect();
  152. $item->refresh();
  153. $this->assertSame('Новое наименование', $item->calculator_name);
  154. $this->assertSame(100.0, $item->builders_price);
  155. }
  156. public function test_export_is_available_only_to_admin_by_default(): void
  157. {
  158. Bus::fake([ExportCommonCatalogJob::class]);
  159. $this->actingAs($this->assistantHead)
  160. ->post(route('common-catalog.export'))
  161. ->assertForbidden();
  162. Bus::assertNotDispatched(ExportCommonCatalogJob::class);
  163. $this->actingAs($this->admin)
  164. ->post(route('common-catalog.export'))
  165. ->assertRedirect(route('common-catalog.index'));
  166. Bus::assertDispatched(ExportCommonCatalogJob::class);
  167. }
  168. public function test_admin_can_create_update_and_delete_item(): void
  169. {
  170. $this->actingAs($this->admin)
  171. ->post(route('common-catalog.store'), $this->validData())
  172. ->assertRedirect();
  173. $item = CommonCatalogItem::query()->where('article', '0007')->firstOrFail();
  174. $updated = $this->validData();
  175. $updated['calculator_name'] = 'Обновлённое наименование';
  176. $this->actingAs($this->admin)
  177. ->put(route('common-catalog.update', $item), $updated)
  178. ->assertRedirect();
  179. $this->assertDatabaseHas('common_catalog_items', [
  180. 'id' => $item->id,
  181. 'article' => '0007',
  182. 'calculator_name' => 'Обновлённое наименование',
  183. ]);
  184. $this->actingAs($this->admin)
  185. ->delete(route('common-catalog.destroy', $item))
  186. ->assertRedirect(route('common-catalog.index'));
  187. $this->assertSoftDeleted('common_catalog_items', ['id' => $item->id]);
  188. }
  189. public function test_article_and_name_are_required_and_their_pair_is_unique(): void
  190. {
  191. CommonCatalogItem::factory()->create([
  192. 'article' => '0007',
  193. 'calculator_name' => 'Игровой комплекс',
  194. ]);
  195. $this->actingAs($this->admin)
  196. ->post(route('common-catalog.store'), [
  197. 'article' => '',
  198. 'calculator_name' => '',
  199. ])
  200. ->assertSessionHasErrors(['article', 'calculator_name']);
  201. $this->actingAs($this->admin)
  202. ->post(route('common-catalog.store'), [
  203. 'article' => '0007',
  204. 'calculator_name' => 'Игровой комплекс',
  205. ])
  206. ->assertSessionHasErrors(['article']);
  207. }
  208. public function test_same_article_is_allowed_for_different_named_variants(): void
  209. {
  210. CommonCatalogItem::factory()->create([
  211. 'article' => '6416',
  212. 'calculator_name' => 'Оборудование игровой площадки',
  213. ]);
  214. $payload = $this->validData();
  215. $payload['article'] = '6416';
  216. $payload['calculator_name'] = 'Оборудование игровой площадки (Цинк)';
  217. $this->actingAs($this->admin)
  218. ->post(route('common-catalog.store'), $payload)
  219. ->assertRedirect();
  220. $this->assertDatabaseCount('common_catalog_items', 2);
  221. }
  222. public function test_item_card_contains_technical_description_and_calculation_placeholders(): void
  223. {
  224. $item = CommonCatalogItem::factory()->create();
  225. $this->actingAs($this->admin)
  226. ->get(route('common-catalog.show', $item))
  227. ->assertOk()
  228. ->assertSeeText('Техническое описание')
  229. ->assertSeeText('Калькуляция');
  230. }
  231. public function test_admin_can_edit_technical_description_fields(): void
  232. {
  233. $item = CommonCatalogItem::factory()->create();
  234. $payload = $this->validData();
  235. $payload['article'] = $item->article;
  236. $payload['print_name'] = 'Наименование для формы';
  237. $payload['product_group'] = 'Игровое оборудование';
  238. $payload['characteristics'] = 'Характеристики';
  239. $payload['technical_description'] = 'Полное описание';
  240. $payload['technical_description_short'] = 'Краткое описание';
  241. $this->actingAs($this->admin)
  242. ->put(route('common-catalog.update', $item), $payload)
  243. ->assertRedirect();
  244. $this->assertDatabaseHas('common_catalog_items', [
  245. 'id' => $item->id,
  246. 'print_name' => 'Наименование для формы',
  247. 'technical_description' => 'Полное описание',
  248. ]);
  249. }
  250. public function test_technical_description_export_requires_access_to_selected_price(): void
  251. {
  252. Bus::fake([ExportTechnicalDescriptionsJob::class]);
  253. $item = CommonCatalogItem::factory()->create();
  254. $payload = [
  255. 'item_ids' => [$item->id],
  256. 'description_mode' => \App\Services\TechnicalDescriptionService::MODE_CHARACTERISTICS_SHORT,
  257. ];
  258. $this->actingAs($this->manager)
  259. ->post(route('common-catalog.technical-description.export'), $payload)
  260. ->assertForbidden();
  261. Bus::assertNotDispatched(ExportTechnicalDescriptionsJob::class);
  262. $this->actingAs($this->admin)
  263. ->post(route('common-catalog.technical-description.export'), $payload)
  264. ->assertRedirect();
  265. Bus::assertDispatched(ExportTechnicalDescriptionsJob::class);
  266. }
  267. public function test_generated_technical_description_is_private_to_owner_or_admin(): void
  268. {
  269. Storage::fake('local');
  270. $owner = User::factory()->create(['role' => Role::MANAGER]);
  271. $otherManager = User::factory()->create(['role' => Role::MANAGER]);
  272. $path = 'generated/technical-descriptions/'.$owner->id.'/test/document.docx';
  273. Storage::disk('local')->put($path, 'docx');
  274. $file = File::query()->create([
  275. 'user_id' => $owner->id,
  276. 'path' => $path,
  277. 'link' => '',
  278. 'original_name' => 'document.docx',
  279. 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  280. 'is_generated' => true,
  281. ]);
  282. $this->actingAs($otherManager)
  283. ->get(route('common-catalog.technical-description.download', $file))
  284. ->assertForbidden();
  285. $this->actingAs($owner)
  286. ->get(route('common-catalog.technical-description.download', $file))
  287. ->assertDownload('document.docx');
  288. $this->actingAs($this->admin)
  289. ->get(route('common-catalog.technical-description.download', $file))
  290. ->assertDownload('document.docx');
  291. }
  292. public function test_admin_can_upload_and_delete_image(): void
  293. {
  294. Storage::fake('public');
  295. $item = CommonCatalogItem::factory()->create();
  296. $this->actingAs($this->admin)
  297. ->post(route('common-catalog.image.upload', $item), [
  298. 'image' => UploadedFile::fake()->image('item.png', 200, 200),
  299. ])
  300. ->assertRedirect();
  301. $item->refresh();
  302. $this->assertNotNull($item->imageFile);
  303. Storage::disk('public')->assertExists($item->imageFile->path);
  304. $fileId = $item->imageFile->id;
  305. $this->actingAs($this->admin)
  306. ->delete(route('common-catalog.image.delete', $item))
  307. ->assertRedirect();
  308. $this->assertDatabaseMissing('files', ['id' => $fileId]);
  309. $this->assertNull($item->refresh()->image_file_id);
  310. }
  311. public function test_admin_can_upload_and_delete_multiple_documents(): void
  312. {
  313. Storage::fake('public');
  314. $item = CommonCatalogItem::factory()->create();
  315. foreach (['certificate.pdf', 'manual.pdf'] as $filename) {
  316. $this->actingAs($this->admin)
  317. ->post(route('common-catalog.documents.upload', $item), [
  318. 'document' => UploadedFile::fake()->create($filename, 20, 'application/pdf'),
  319. ])
  320. ->assertRedirect();
  321. }
  322. $this->assertCount(2, $item->refresh()->documents);
  323. $document = $item->documents->first();
  324. $this->actingAs($this->admin)
  325. ->delete(route('common-catalog.documents.delete', [$item, $document]))
  326. ->assertRedirect();
  327. $this->assertCount(1, $item->refresh()->documents);
  328. $this->assertDatabaseMissing('files', ['id' => $document->id]);
  329. }
  330. public function test_import_and_export_are_queued(): void
  331. {
  332. Storage::fake('upload');
  333. Bus::fake([ImportJob::class, ExportCommonCatalogJob::class]);
  334. $this->actingAs($this->admin)
  335. ->post(route('common-catalog.import'), [
  336. 'import_file' => UploadedFile::fake()->create(
  337. 'common-catalog.xlsx',
  338. 20,
  339. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  340. ),
  341. ])
  342. ->assertRedirect(route('common-catalog.index'));
  343. $this->assertDatabaseHas('imports', [
  344. 'type' => 'common_catalog',
  345. 'year' => null,
  346. 'user_id' => $this->admin->id,
  347. ]);
  348. Bus::assertDispatched(ImportJob::class);
  349. $this->actingAs($this->admin)
  350. ->post(route('common-catalog.export'))
  351. ->assertRedirect(route('common-catalog.index'));
  352. Bus::assertDispatched(ExportCommonCatalogJob::class);
  353. }
  354. private function validData(): array
  355. {
  356. return [
  357. 'article' => '0007',
  358. 'calculator_name' => "0007\nИгровой комплекс",
  359. 'kind' => 'Игровое оборудование',
  360. 'dimension_length' => '2',
  361. 'dimension_width' => '3',
  362. 'dimension_height' => '1,5',
  363. 'site_length' => '5',
  364. 'site_width' => '6',
  365. 'fall_height' => 1.2,
  366. 'dimension_unit' => 'м',
  367. 'weight' => 350.5,
  368. 'volume' => 4.25,
  369. 'places' => 3,
  370. 'age_group' => '3+',
  371. 'max_users' => 8,
  372. 'unit' => 'шт.',
  373. 'series' => 'Двор',
  374. 'trademark' => 'Наш Двор',
  375. 'calculator_enabled' => true,
  376. 'builders_price' => 100000,
  377. 'wholesale_price' => 105000,
  378. 'recommended_price' => 110000,
  379. 'retail_price' => 150000,
  380. 'project_price' => 200000,
  381. 'project_with_installation_price' => 240000,
  382. 'pik_price' => 110000,
  383. 'recommended_plus_10_price' => 121000,
  384. ];
  385. }
  386. }