app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setEventDispatcher(new Dispatcher($this->app)); $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('log', new \Psr\Log\NullLogger()); $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 = Request::create('/admin/site/config'); $request->user = (object) ['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request); DB::statement('CREATE TABLE bot_operation_audits (id INTEGER PRIMARY KEY AUTOINCREMENT,resource_type TEXT,resource_id INTEGER,action TEXT,changes TEXT,operator_id INTEGER,operator_name TEXT,created_at TEXT)'); DB::statement('CREATE TABLE bot_menus (id INTEGER PRIMARY KEY,title TEXT)'); DB::table('menus')->insert(['id' => 1, 'title' => '前端已有菜单']); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_120000_create_site_settings.php')->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php')->up(); $this->service = new SiteConfigurationService(); } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function emptyConfig(): array { return ['translations' => ['zh' => ['content' => []], 'en' => ['content' => []], 'vi' => ['content' => []]], 'domains' => []]; } public function test_defaults_and_migration_do_not_require_a_separate_configuration_record_or_touch_menus(): void { $this->assertSame($this->emptyConfig(), $this->service->get()); $this->assertSame(1, DB::table('menus')->count()); $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_120000_create_site_settings.php'; $migration->down(); $this->assertSame('前端已有菜单', DB::table('menus')->value('title')); $migration->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php')->up(); $this->assertSame($this->emptyConfig(), $this->service->get()); } public function test_both_fields_save_together_preserve_domain_order_and_return_normalized_values(): void { $result = $this->service->save(['translations' => ['zh' => ['content' => [' 欢迎体验 ', ' 第二条 ', ' ', '', '欢迎体验']], 'en' => ['content' => [' Welcome ', 'Second item']], 'vi' => ['content' => [' Chào mừng ']]], 'domains' => ['example.com', 'https://EXAMPLE.com/', 'HTTP://BACKUP.EXAMPLE:8080/']]); $this->assertSame(['translations' => ['zh' => ['content' => ['欢迎体验', '第二条', '欢迎体验']], 'en' => ['content' => ['Welcome', 'Second item']], 'vi' => ['content' => ['Chào mừng']]], 'domains' => ['https://example.com', 'http://backup.example:8080']], $result); $this->assertSame($result, $this->service->get()); $this->assertSame(1, DB::table('operation_audits')->count()); $this->service->save($result); $this->assertSame(1, DB::table('operation_audits')->count()); $this->assertSame($this->emptyConfig(), $this->service->save(['translations' => [], 'domains' => []])); } /** @dataProvider invalidPayloads */ public function test_invalid_configuration_does_not_partially_save(array $payload): void { try { $this->service->save($payload); $this->fail('Expected validation failure'); } catch (ValidationException $e) { $this->assertNotEmpty($e->validator->errors()->all()); } $this->assertSame($this->emptyConfig(), $this->service->get()); $this->assertSame(0, DB::table('operation_audits')->count()); } public static function invalidPayloads(): array { $base = ['translations' => ['zh' => ['content' => ['测试']]], 'domains' => []]; return [[['domains' => []]], [['translations' => []]], [array_replace($base, ['translations' => ['zh' => ['content' => [str_repeat('字', 2001)]]]])], [array_replace($base, ['translations' => ['zh' => ['content' => '旧字符串']]])], [array_replace($base, ['translations' => ['zh' => ['content' => '']]])], [array_replace($base, ['translations' => ['zh' => ['content' => null]]])], [array_replace($base, ['translations' => ['zh' => ['content' => [123]]]])], [array_replace($base, ['translations' => ['zh' => ['content' => [false]]]])], [array_replace($base, ['translations' => ['zh' => ['content' => [['nested']]]]])], [array_replace($base, ['translations' => ['zh' => ['content' => ['first' => '文字']]]])], [array_replace($base, ['translations' => ['zh' => ['content' => array_fill(0, 101, '文字')]]])], [array_replace($base, ['domains' => ['primary' => 'https://example.com']])], [array_replace($base, ['domains' => array_fill(0, 101, 'https://example.com')])], [array_replace($base, ['domains' => ['']])], [array_replace($base, ['domains' => [123]])], [array_replace($base, ['domains' => ['ftp://example.com']])], [array_replace($base, ['domains' => ['javascript:alert(1)']])], [array_replace($base, ['domains' => ['https://user:pass@example.com']])], [array_replace($base, ['domains' => ['https://example.com/path']])], [array_replace($base, ['domains' => ['https://example.com?x=1']])], [array_replace($base, ['domains' => ['https://example.com/#fragment']])], [array_replace($base, ['translations' => ['fr' => ['content' => ['French']]]])], [array_replace($base, ['translations' => ['zh' => '不是对象']])], [array_replace($base, ['translations' => ['zh' => ['title' => '未知字段']]])]]; } public function test_translations_are_independent_and_full_replacement_clears_omitted_languages(): void { $this->service->save(['translations' => ['zh' => ['content' => ['中文']], 'en' => ['content' => ['English']], 'vi' => ['content' => ['Tiếng Việt']]], 'domains' => ['example.com']]); $this->assertSame(3, DB::table('site_setting_translations')->count()); $next = $this->service->save(['translations' => ['zh' => ['content' => ['新中文', '新增一条']]], 'domains' => ['shared.example']]); $this->assertSame(['zh' => ['content' => ['新中文', '新增一条']], 'en' => ['content' => []], 'vi' => ['content' => []]], $next['translations']); $this->assertSame(['https://shared.example'], $next['domains']); $this->assertSame(1, DB::table('site_setting_translations')->count()); } public function test_audit_failure_rolls_back_both_configuration_fields(): void { $before = $this->service->save(['translations' => ['zh' => ['content' => ['原内容', '原第二条']]], 'domains' => ['example.com']]); DB::statement('DROP TABLE bot_operation_audits'); try { $this->service->save(['translations' => ['zh' => ['content' => ['新内容', '新第二条']]], 'domains' => ['new.example']]); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertSame($before, $this->service->get()); } } public function test_existing_single_text_survives_migration_and_becomes_one_array_item(): void { $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php'; $migration->down(); DB::table('site_setting_translations')->insert([ ['site_setting_id' => 1, 'language' => 'zh', 'content' => '["原有内容"]'], ['site_setting_id' => 1, 'language' => 'en', 'content' => ''], ]); $migration->up(); $config = $this->service->get(); $this->assertSame(['["原有内容"]'], $config['translations']['zh']['content']); $this->assertSame([], $config['translations']['en']['content']); $config['translations']['zh']['content'][] = '新增一条'; $this->assertSame($config, $this->service->save($config)); $this->assertSame($config, $this->service->get()); $stored = DB::table('site_setting_translations')->where('language', 'zh')->first(); $this->assertSame('["原有内容"]', $stored->content); $this->assertSame($config['translations']['zh']['content'], json_decode($stored->contents, true)); $migration->down(); $this->assertSame('["原有内容"]', DB::table('site_setting_translations')->where('language', 'zh')->value('content')); } public function test_maximum_list_round_trips_and_an_explicit_empty_list_clears_one_language(): void { $items = array_fill(0, 100, str_repeat('字', 2000)); $result = $this->service->save(['translations' => ['zh' => ['content' => $items], 'en' => ['content' => ['English']]], 'domains' => []]); $this->assertSame($items, $result['translations']['zh']['content']); $this->assertSame($result, $this->service->get()); $result['translations']['zh']['content'] = []; $this->assertSame($result, $this->service->save($result)); $this->assertSame($result, $this->service->get()); $this->assertSame(['English'], $result['translations']['en']['content']); } public function test_invalid_list_does_not_change_an_existing_configuration(): void { $before = $this->service->save(['translations' => ['zh' => ['content' => ['第一条', '第二条']]], 'domains' => ['example.com']]); try { $this->service->save(['translations' => ['zh' => ['content' => ['first' => '错误列表']]], 'domains' => ['new.example']]); $this->fail('Expected validation failure'); } catch (ValidationException $e) { $this->assertSame($before, $this->service->get()); $this->assertSame(1, DB::table('operation_audits')->count()); } } public function test_admin_save_after_request_normalization_ignores_blank_items(): void { $request = Request::create('/admin/site/config', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ 'translations' => ['zh' => ['content' => [' 第一条 ', '', ' ', '第二条']]], 'domains' => [], ], JSON_UNESCAPED_UNICODE)); $response = (new \App\Http\Middleware\TrimStrings())->handle($request, function ($request) { return (new \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull())->handle($request, function ($request) { $this->app->instance('request', $request); return (new SiteConfiguration())->save($this->service); }); }); $this->assertSame(0, $response->getData(true)['code']); $this->assertSame(['第一条', '第二条'], $response->getData(true)['data']['translations']['zh']['content']); $this->assertSame(['第一条', '第二条'], $this->service->get()['translations']['zh']['content']); } public function test_one_admin_path_supports_read_and_write_with_authentication_and_permission_checks(): void { $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); $get = $router->getRoutes()->match(Request::create('/admin/site/config', 'GET')); $post = $router->getRoutes()->match(Request::create('/admin/site/config', 'POST')); foreach ([$get, $post] as $route) { $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware()); } $this->assertContains('operations.admin', $post->middleware()); $this->assertSame('admin/site/config/save', $post->defaults['permission_uri']); $response = (new SiteConfiguration())->show($this->service); $this->assertSame(0, $response->getData(true)['code']); $this->assertSame($this->emptyConfig(), $response->getData(true)['data']); $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control')); } }