MenuIconTest.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Menu as MenuController;
  4. use App\Models\Menu;
  5. use Illuminate\Config\Repository;
  6. use Illuminate\Database\Capsule\Manager;
  7. use Illuminate\Database\Schema\Blueprint;
  8. use Illuminate\Foundation\Application;
  9. use Illuminate\Http\JsonResponse;
  10. use Illuminate\Http\Request;
  11. use Illuminate\Support\Facades\DB;
  12. use Illuminate\Support\Facades\Facade;
  13. use Illuminate\Translation\ArrayLoader;
  14. use Illuminate\Translation\Translator;
  15. use Illuminate\Validation\Factory;
  16. use PHPUnit\Framework\TestCase;
  17. class MenuIconTest extends TestCase
  18. {
  19. private Application $app;
  20. protected function setUp(): void
  21. {
  22. Facade::clearResolvedInstances();
  23. require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
  24. $this->app = new Application(dirname(__DIR__, 2));
  25. $this->app->instance('config', new Repository(['app' => ['url' => 'https://admin.example.test', 'locale' => 'zh']]));
  26. Facade::setFacadeApplication($this->app);
  27. $db = new Manager($this->app);
  28. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  29. $db->setAsGlobal();
  30. $db->bootEloquent();
  31. $this->app->instance('db', $db->getDatabaseManager());
  32. $this->app->bind('db.schema', fn () => $db->schema());
  33. $translator = new Translator(new ArrayLoader(), 'zh');
  34. $this->app->instance('translator', $translator);
  35. $this->app->instance('validator', new Factory($translator, $this->app));
  36. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
  37. public function json($data, $status = 200, $headers = [], $options = 0) {
  38. return new JsonResponse($data, $status, $headers, $options);
  39. }
  40. });
  41. Request::macro('validate', function (array $rules) {
  42. return app('validator')->make($this->all(), $rules)->validate();
  43. });
  44. $db->schema()->create('menus', function (Blueprint $t) {
  45. $t->id(); $t->unsignedBigInteger('parent_id')->default(0); $t->string('title');
  46. $t->string('icon', 50)->nullable(); $t->string('uri'); $t->string('permission_name')->nullable();
  47. $t->integer('sort')->default(0); $t->integer('status')->default(1); $t->integer('type')->default(1); $t->timestamps();
  48. });
  49. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_29_120000_expand_menu_icon_for_uploaded_images.php')->up();
  50. foreach (['roles' => ['name', 'display_name', 'description'], 'role_user' => ['user_id', 'role_id'], 'role_menu' => ['menu_id', 'role_id']] as $table => $fields) {
  51. $db->schema()->create($table, function (Blueprint $t) use ($fields) {
  52. $t->id();
  53. foreach ($fields as $field) $t->string($field)->nullable();
  54. $t->timestamps();
  55. });
  56. }
  57. $this->request('/admin/menu/all');
  58. }
  59. protected function tearDown(): void
  60. {
  61. DB::disconnect();
  62. Facade::clearResolvedInstances();
  63. parent::tearDown();
  64. }
  65. private function request(string $path, array $params = [], string $method = 'GET'): void
  66. {
  67. $this->app->instance('request', Request::create($path, $method, $params));
  68. }
  69. private function save(array $params = []): array
  70. {
  71. $this->request('/admin/menu/submit', $params + ['title' => '报表', 'uri' => '/reports', 'parent_id' => 0, 'status' => 1, 'sort' => 0, 'type' => 1], 'POST');
  72. return (new MenuController())->store()->getData(true);
  73. }
  74. private function allMenus(): array
  75. {
  76. $this->request('/admin/menu/all');
  77. $body = (new MenuController())->all()->getData(true);
  78. $this->assertSame(0, $body['code']);
  79. return $body['data'];
  80. }
  81. public function test_uploaded_image_roundtrips_in_parent_and_child_menu_nodes(): void
  82. {
  83. $path = '/storage/images/' . str_repeat('12345678', 8) . '.png';
  84. $url = 'https://admin.example.test' . $path;
  85. $this->assertSame(0, $this->save(['icon' => $url])['code']);
  86. $parent = (int) DB::table('menus')->value('id');
  87. $this->assertSame($path, DB::table('menus')->where('id', $parent)->value('icon'));
  88. $this->assertSame(0, $this->save(['title' => '子菜单', 'uri' => '/reports/daily', 'parent_id' => $parent, 'icon' => '/storage/images/child.webp'])['code']);
  89. $tree = $this->allMenus();
  90. $this->assertSame($url, $tree[0]['icon']);
  91. $this->assertSame('https://admin.example.test/storage/images/child.webp', $tree[0]['children'][0]['icon']);
  92. $this->assertSame('/reports/daily', $tree[0]['children'][0]['uri']);
  93. $this->assertSame(0, $this->save(['id' => $parent, 'icon' => $tree[0]['icon']])['code']);
  94. $this->assertSame($path, DB::table('menus')->where('id', $parent)->value('icon'));
  95. }
  96. public function test_icon_can_be_retained_replaced_with_cdn_url_and_cleared(): void
  97. {
  98. $this->assertSame(0, $this->save(['icon' => '/storage/images/menu.png'])['code']);
  99. $id = (int) DB::table('menus')->value('id');
  100. $this->assertSame(0, $this->save(['id' => $id, 'title' => '改名'])['code']);
  101. $this->assertSame('https://admin.example.test/storage/images/menu.png', $this->allMenus()[0]['icon']);
  102. $url = 'https://cdn.example.test/menu.png?version=2';
  103. $this->assertSame(0, $this->save(['id' => $id, 'icon' => $url])['code']);
  104. $this->assertSame($url, $this->allMenus()[0]['icon']);
  105. foreach ([null, ''] as $empty) {
  106. $this->assertSame(0, $this->save(['id' => $id, 'icon' => '/storage/images/menu.png'])['code']);
  107. $this->assertSame(0, $this->save(['id' => $id, 'icon' => $empty])['code']);
  108. $this->assertSame('', $this->allMenus()[0]['icon']);
  109. }
  110. }
  111. public function test_class_names_invalid_values_and_oversized_icons_are_rejected_without_writes(): void
  112. {
  113. foreach (['ri-home-line', 'fa fa-home', 'javascript:alert(1)', '//other.example.test/icon.png', ['icon'], 'https://cdn.example.test/' . str_repeat('x', 500)] as $value) {
  114. $this->assertNotSame(0, $this->save(['icon' => $value])['code']);
  115. $this->assertSame(0, DB::table('menus')->count());
  116. }
  117. }
  118. public function test_existing_class_icons_are_empty_and_role_menus_use_uploaded_images(): void
  119. {
  120. DB::table('menus')->insert(['id' => 1, 'parent_id' => 0, 'title' => '旧菜单', 'uri' => '/legacy', 'icon' => 'ri-home-line']);
  121. $this->assertSame(0, $this->save(['icon' => '/storage/images/allowed.png'])['code']);
  122. $id = (int) DB::table('menus')->where('uri', '/reports')->value('id');
  123. $this->assertSame('', $this->allMenus()[0]['icon']);
  124. $this->assertSame('ri-home-line', DB::table('menus')->where('id', 1)->value('icon'));
  125. DB::table('roles')->insert(['id' => 2, 'name' => 'viewer']);
  126. DB::table('role_user')->insert(['user_id' => 20, 'role_id' => 2]);
  127. DB::table('role_menu')->insert(['menu_id' => $id, 'role_id' => 2]);
  128. $this->request('/admin/menu/mineMenu');
  129. request()->user = (object) ['id' => 20];
  130. $body = (new MenuController())->mineMenu()->getData(true);
  131. $this->assertSame(0, $body['code']);
  132. $this->assertCount(1, $body['data']);
  133. $this->assertSame($id, $body['data'][0]['id']);
  134. $this->assertSame('https://admin.example.test/storage/images/allowed.png', $body['data'][0]['icon']);
  135. }
  136. }