SiteConfigurationTest.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\SiteConfiguration;
  4. use App\Services\SiteConfigurationService;
  5. use Illuminate\Config\Repository;
  6. use Illuminate\Database\Capsule\Manager;
  7. use Illuminate\Events\Dispatcher;
  8. use Illuminate\Foundation\Application;
  9. use Illuminate\Http\JsonResponse;
  10. use Illuminate\Http\Request;
  11. use Illuminate\Routing\Router;
  12. use Illuminate\Support\Facades\DB;
  13. use Illuminate\Support\Facades\Facade;
  14. use Illuminate\Translation\ArrayLoader;
  15. use Illuminate\Translation\Translator;
  16. use Illuminate\Validation\Factory;
  17. use Illuminate\Validation\ValidationException;
  18. use PHPUnit\Framework\Attributes\DataProvider;
  19. use PHPUnit\Framework\TestCase;
  20. class SiteConfigurationTest extends TestCase
  21. {
  22. private Application $app;
  23. private SiteConfigurationService $service;
  24. protected function setUp(): void
  25. {
  26. Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
  27. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  28. Facade::setFacadeApplication($this->app);
  29. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  30. $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent();
  31. $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
  32. $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
  33. $this->app->instance('validator', new Factory($translator, $this->app));
  34. $this->app->instance('log', new \Psr\Log\NullLogger());
  35. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  36. $request = Request::create('/admin/site/config'); $request->user = (object) ['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request);
  37. 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)');
  38. DB::statement('CREATE TABLE bot_menus (id INTEGER PRIMARY KEY,title TEXT)');
  39. DB::table('menus')->insert(['id' => 1, 'title' => '前端已有菜单']);
  40. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_120000_create_site_settings.php')->up();
  41. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php')->up();
  42. $this->service = new SiteConfigurationService();
  43. }
  44. protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  45. private function emptyConfig(): array { return ['translations' => ['zh' => ['content' => []], 'en' => ['content' => []], 'vi' => ['content' => []]], 'domains' => []]; }
  46. public function test_defaults_and_migration_do_not_require_a_separate_configuration_record_or_touch_menus(): void
  47. {
  48. $this->assertSame($this->emptyConfig(), $this->service->get());
  49. $this->assertSame(1, DB::table('menus')->count());
  50. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_120000_create_site_settings.php';
  51. $migration->down();
  52. $this->assertSame('前端已有菜单', DB::table('menus')->value('title'));
  53. $migration->up();
  54. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php')->up();
  55. $this->assertSame($this->emptyConfig(), $this->service->get());
  56. }
  57. public function test_both_fields_save_together_preserve_domain_order_and_return_normalized_values(): void
  58. {
  59. $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/']]);
  60. $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);
  61. $this->assertSame($result, $this->service->get());
  62. $this->assertSame(1, DB::table('operation_audits')->count());
  63. $this->service->save($result);
  64. $this->assertSame(1, DB::table('operation_audits')->count());
  65. $this->assertSame($this->emptyConfig(), $this->service->save(['translations' => [], 'domains' => []]));
  66. }
  67. #[DataProvider('invalidPayloads')]
  68. public function test_invalid_configuration_does_not_partially_save(array $payload): void
  69. {
  70. try { $this->service->save($payload); $this->fail('Expected validation failure'); }
  71. catch (ValidationException $e) { $this->assertNotEmpty($e->validator->errors()->all()); }
  72. $this->assertSame($this->emptyConfig(), $this->service->get());
  73. $this->assertSame(0, DB::table('operation_audits')->count());
  74. }
  75. public static function invalidPayloads(): array
  76. {
  77. $base = ['translations' => ['zh' => ['content' => ['测试']]], 'domains' => []];
  78. return [[['domains' => []]], [['translations' => []]],
  79. [array_replace($base, ['translations' => ['zh' => ['content' => [str_repeat('字', 2001)]]]])],
  80. [array_replace($base, ['translations' => ['zh' => ['content' => '旧字符串']]])],
  81. [array_replace($base, ['translations' => ['zh' => ['content' => '']]])],
  82. [array_replace($base, ['translations' => ['zh' => ['content' => null]]])],
  83. [array_replace($base, ['translations' => ['zh' => ['content' => [123]]]])],
  84. [array_replace($base, ['translations' => ['zh' => ['content' => [false]]]])],
  85. [array_replace($base, ['translations' => ['zh' => ['content' => [['nested']]]]])],
  86. [array_replace($base, ['translations' => ['zh' => ['content' => ['first' => '文字']]]])],
  87. [array_replace($base, ['translations' => ['zh' => ['content' => array_fill(0, 101, '文字')]]])],
  88. [array_replace($base, ['domains' => ['primary' => 'https://example.com']])],
  89. [array_replace($base, ['domains' => array_fill(0, 101, 'https://example.com')])],
  90. [array_replace($base, ['domains' => ['']])], [array_replace($base, ['domains' => [123]])],
  91. [array_replace($base, ['domains' => ['ftp://example.com']])],
  92. [array_replace($base, ['domains' => ['javascript:alert(1)']])],
  93. [array_replace($base, ['domains' => ['https://user:pass@example.com']])],
  94. [array_replace($base, ['domains' => ['https://example.com/path']])],
  95. [array_replace($base, ['domains' => ['https://example.com?x=1']])],
  96. [array_replace($base, ['domains' => ['https://example.com/#fragment']])],
  97. [array_replace($base, ['translations' => ['fr' => ['content' => ['French']]]])],
  98. [array_replace($base, ['translations' => ['zh' => '不是对象']])],
  99. [array_replace($base, ['translations' => ['zh' => ['title' => '未知字段']]])]];
  100. }
  101. public function test_translations_are_independent_and_full_replacement_clears_omitted_languages(): void
  102. {
  103. $this->service->save(['translations' => ['zh' => ['content' => ['中文']], 'en' => ['content' => ['English']], 'vi' => ['content' => ['Tiếng Việt']]], 'domains' => ['example.com']]);
  104. $this->assertSame(3, DB::table('site_setting_translations')->count());
  105. $next = $this->service->save(['translations' => ['zh' => ['content' => ['新中文', '新增一条']]], 'domains' => ['shared.example']]);
  106. $this->assertSame(['zh' => ['content' => ['新中文', '新增一条']], 'en' => ['content' => []], 'vi' => ['content' => []]], $next['translations']);
  107. $this->assertSame(['https://shared.example'], $next['domains']);
  108. $this->assertSame(1, DB::table('site_setting_translations')->count());
  109. }
  110. public function test_audit_failure_rolls_back_both_configuration_fields(): void
  111. {
  112. $before = $this->service->save(['translations' => ['zh' => ['content' => ['原内容', '原第二条']]], 'domains' => ['example.com']]);
  113. DB::statement('DROP TABLE bot_operation_audits');
  114. try { $this->service->save(['translations' => ['zh' => ['content' => ['新内容', '新第二条']]], 'domains' => ['new.example']]); $this->fail('Expected audit failure'); }
  115. catch (\Illuminate\Database\QueryException $e) { $this->assertSame($before, $this->service->get()); }
  116. }
  117. public function test_existing_single_text_survives_migration_and_becomes_one_array_item(): void
  118. {
  119. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_31_120000_add_contents_to_site_setting_translations.php';
  120. $migration->down();
  121. DB::table('site_setting_translations')->insert([
  122. ['site_setting_id' => 1, 'language' => 'zh', 'content' => '["原有内容"]'],
  123. ['site_setting_id' => 1, 'language' => 'en', 'content' => ''],
  124. ]);
  125. $migration->up();
  126. $config = $this->service->get();
  127. $this->assertSame(['["原有内容"]'], $config['translations']['zh']['content']);
  128. $this->assertSame([], $config['translations']['en']['content']);
  129. $config['translations']['zh']['content'][] = '新增一条';
  130. $this->assertSame($config, $this->service->save($config));
  131. $this->assertSame($config, $this->service->get());
  132. $stored = DB::table('site_setting_translations')->where('language', 'zh')->first();
  133. $this->assertSame('["原有内容"]', $stored->content);
  134. $this->assertSame($config['translations']['zh']['content'], json_decode($stored->contents, true));
  135. $migration->down();
  136. $this->assertSame('["原有内容"]', DB::table('site_setting_translations')->where('language', 'zh')->value('content'));
  137. }
  138. public function test_maximum_list_round_trips_and_an_explicit_empty_list_clears_one_language(): void
  139. {
  140. $items = array_fill(0, 100, str_repeat('字', 2000));
  141. $result = $this->service->save(['translations' => ['zh' => ['content' => $items], 'en' => ['content' => ['English']]], 'domains' => []]);
  142. $this->assertSame($items, $result['translations']['zh']['content']);
  143. $this->assertSame($result, $this->service->get());
  144. $result['translations']['zh']['content'] = [];
  145. $this->assertSame($result, $this->service->save($result));
  146. $this->assertSame($result, $this->service->get());
  147. $this->assertSame(['English'], $result['translations']['en']['content']);
  148. }
  149. public function test_invalid_list_does_not_change_an_existing_configuration(): void
  150. {
  151. $before = $this->service->save(['translations' => ['zh' => ['content' => ['第一条', '第二条']]], 'domains' => ['example.com']]);
  152. try {
  153. $this->service->save(['translations' => ['zh' => ['content' => ['first' => '错误列表']]], 'domains' => ['new.example']]);
  154. $this->fail('Expected validation failure');
  155. } catch (ValidationException $e) {
  156. $this->assertSame($before, $this->service->get());
  157. $this->assertSame(1, DB::table('operation_audits')->count());
  158. }
  159. }
  160. public function test_admin_save_after_request_normalization_ignores_blank_items(): void
  161. {
  162. $request = Request::create('/admin/site/config', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
  163. 'translations' => ['zh' => ['content' => [' 第一条 ', '', ' ', '第二条']]], 'domains' => [],
  164. ], JSON_UNESCAPED_UNICODE));
  165. $response = (new \App\Http\Middleware\TrimStrings())->handle($request, function ($request) {
  166. return (new \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull())->handle($request, function ($request) {
  167. $this->app->instance('request', $request);
  168. return (new SiteConfiguration())->save($this->service);
  169. });
  170. });
  171. $this->assertSame(0, $response->getData(true)['code']);
  172. $this->assertSame(['第一条', '第二条'], $response->getData(true)['data']['translations']['zh']['content']);
  173. $this->assertSame(['第一条', '第二条'], $this->service->get()['translations']['zh']['content']);
  174. }
  175. public function test_one_admin_path_supports_read_and_write_with_authentication_and_permission_checks(): void
  176. {
  177. $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  178. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  179. $get = $router->getRoutes()->match(Request::create('/admin/site/config', 'GET'));
  180. $post = $router->getRoutes()->match(Request::create('/admin/site/config', 'POST'));
  181. foreach ([$get, $post] as $route) {
  182. $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
  183. }
  184. $this->assertContains('operations.admin', $post->middleware());
  185. $this->assertSame('admin/site/config/save', $post->defaults['permission_uri']);
  186. $response = (new SiteConfiguration())->show($this->service);
  187. $this->assertSame(0, $response->getData(true)['code']);
  188. $this->assertSame($this->emptyConfig(), $response->getData(true)['data']);
  189. $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
  190. }
  191. }