app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['url' => 'https://admin.example.test', 'locale' => 'zh']])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class { public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); } }); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); $db->schema()->create('menus', function (Blueprint $t) { $t->id(); $t->unsignedBigInteger('parent_id')->default(0); $t->string('title'); $t->string('icon', 50)->nullable(); $t->string('uri'); $t->string('permission_name')->nullable(); $t->integer('sort')->default(0); $t->integer('status')->default(1); $t->integer('type')->default(1); $t->timestamps(); }); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_29_120000_expand_menu_icon_for_uploaded_images.php')->up(); foreach (['roles' => ['name', 'display_name', 'description'], 'role_user' => ['user_id', 'role_id'], 'role_menu' => ['menu_id', 'role_id']] as $table => $fields) { $db->schema()->create($table, function (Blueprint $t) use ($fields) { $t->id(); foreach ($fields as $field) $t->string($field)->nullable(); $t->timestamps(); }); } $this->request('/admin/menu/all'); } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function request(string $path, array $params = [], string $method = 'GET'): void { $this->app->instance('request', Request::create($path, $method, $params)); } private function save(array $params = []): array { $this->request('/admin/menu/submit', $params + ['title' => '报表', 'uri' => '/reports', 'parent_id' => 0, 'status' => 1, 'sort' => 0, 'type' => 1], 'POST'); return (new MenuController())->store()->getData(true); } private function allMenus(): array { $this->request('/admin/menu/all'); $body = (new MenuController())->all()->getData(true); $this->assertSame(0, $body['code']); return $body['data']; } public function test_uploaded_image_roundtrips_in_parent_and_child_menu_nodes(): void { $path = '/storage/images/' . str_repeat('12345678', 8) . '.png'; $url = 'https://admin.example.test' . $path; $this->assertSame(0, $this->save(['icon' => $url])['code']); $parent = (int) DB::table('menus')->value('id'); $this->assertSame($path, DB::table('menus')->where('id', $parent)->value('icon')); $this->assertSame(0, $this->save(['title' => '子菜单', 'uri' => '/reports/daily', 'parent_id' => $parent, 'icon' => '/storage/images/child.webp'])['code']); $tree = $this->allMenus(); $this->assertSame($url, $tree[0]['icon']); $this->assertSame('https://admin.example.test/storage/images/child.webp', $tree[0]['children'][0]['icon']); $this->assertSame('/reports/daily', $tree[0]['children'][0]['uri']); $this->assertSame(0, $this->save(['id' => $parent, 'icon' => $tree[0]['icon']])['code']); $this->assertSame($path, DB::table('menus')->where('id', $parent)->value('icon')); } public function test_icon_can_be_retained_replaced_with_cdn_url_and_cleared(): void { $this->assertSame(0, $this->save(['icon' => '/storage/images/menu.png'])['code']); $id = (int) DB::table('menus')->value('id'); $this->assertSame(0, $this->save(['id' => $id, 'title' => '改名'])['code']); $this->assertSame('https://admin.example.test/storage/images/menu.png', $this->allMenus()[0]['icon']); $url = 'https://cdn.example.test/menu.png?version=2'; $this->assertSame(0, $this->save(['id' => $id, 'icon' => $url])['code']); $this->assertSame($url, $this->allMenus()[0]['icon']); foreach ([null, ''] as $empty) { $this->assertSame(0, $this->save(['id' => $id, 'icon' => '/storage/images/menu.png'])['code']); $this->assertSame(0, $this->save(['id' => $id, 'icon' => $empty])['code']); $this->assertSame('', $this->allMenus()[0]['icon']); } } public function test_class_names_invalid_values_and_oversized_icons_are_rejected_without_writes(): void { 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) { $this->assertNotSame(0, $this->save(['icon' => $value])['code']); $this->assertSame(0, DB::table('menus')->count()); } } public function test_existing_class_icons_are_empty_and_role_menus_use_uploaded_images(): void { DB::table('menus')->insert(['id' => 1, 'parent_id' => 0, 'title' => '旧菜单', 'uri' => '/legacy', 'icon' => 'ri-home-line']); $this->assertSame(0, $this->save(['icon' => '/storage/images/allowed.png'])['code']); $id = (int) DB::table('menus')->where('uri', '/reports')->value('id'); $this->assertSame('', $this->allMenus()[0]['icon']); $this->assertSame('ri-home-line', DB::table('menus')->where('id', 1)->value('icon')); DB::table('roles')->insert(['id' => 2, 'name' => 'viewer']); DB::table('role_user')->insert(['user_id' => 20, 'role_id' => 2]); DB::table('role_menu')->insert(['menu_id' => $id, 'role_id' => 2]); $this->request('/admin/menu/mineMenu'); request()->user = (object) ['id' => 20]; $body = (new MenuController())->mineMenu()->getData(true); $this->assertSame(0, $body['code']); $this->assertCount(1, $body['data']); $this->assertSame($id, $body['data'][0]['id']); $this->assertSame('https://admin.example.test/storage/images/allowed.png', $body['data'][0]['icon']); } }