AnnouncementTest.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Announcement as AnnouncementController;
  4. use App\Services\AnnouncementService;
  5. use App\Support\AnnouncementCatalog;
  6. use Carbon\Carbon;
  7. use Illuminate\Config\Repository;
  8. use Illuminate\Database\Capsule\Manager;
  9. use Illuminate\Database\Schema\Blueprint;
  10. use Illuminate\Events\Dispatcher;
  11. use Illuminate\Foundation\Application;
  12. use Illuminate\Http\JsonResponse;
  13. use Illuminate\Http\Request;
  14. use Illuminate\Routing\Router;
  15. use Illuminate\Support\Facades\DB;
  16. use Illuminate\Support\Facades\Facade;
  17. use Illuminate\Support\Facades\Schema;
  18. use Illuminate\Translation\ArrayLoader;
  19. use Illuminate\Translation\Translator;
  20. use Illuminate\Validation\Factory;
  21. use Illuminate\Validation\ValidationException;
  22. use PHPUnit\Framework\TestCase;
  23. /** Actual admin controller, migration, publication and audit queries, with isolated SQLite only. */
  24. class AnnouncementTest extends TestCase
  25. {
  26. private Application $app;
  27. private AnnouncementService $service;
  28. protected function setUp(): void
  29. {
  30. parent::setUp();
  31. Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
  32. Facade::clearResolvedInstances();
  33. $this->app = new Application(dirname(__DIR__, 2));
  34. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai', 'url' => 'https://admin.example.test']]));
  35. Facade::setFacadeApplication($this->app);
  36. $db = new Manager($this->app);
  37. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  38. $db->setAsGlobal(); $db->bootEloquent();
  39. $this->app->instance('db', $db->getDatabaseManager());
  40. $this->app->bind('db.schema', fn () => $db->schema());
  41. $translator = new Translator(new ArrayLoader(), 'zh');
  42. $this->app->instance('translator', $translator);
  43. $this->app->instance('validator', new Factory($translator, $this->app));
  44. $this->app->instance('log', new \Psr\Log\NullLogger());
  45. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
  46. public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
  47. });
  48. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  49. Schema::create('operation_audits', function (Blueprint $t) {
  50. $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
  51. $t->integer('operator_id'); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
  52. });
  53. Schema::create('activity_rewards', function (Blueprint $t) { $t->id(); $t->string('title'); $t->string('click_type')->default('none'); $t->string('link', 2048)->nullable(); });
  54. DB::table('activity_rewards')->insert(['id' => 1, 'title' => '活动']);
  55. $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
  56. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_30_120000_add_platform_links_to_announcements_and_activities.php')->up();
  57. $this->service = new AnnouncementService();
  58. $this->request();
  59. }
  60. protected function tearDown(): void
  61. {
  62. Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
  63. }
  64. private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_100000_create_announcements.php'; }
  65. private function request(array $params = [], string $operator = 'admin'): void
  66. {
  67. $request = Request::create('/admin/announcement/submit', 'POST', $params);
  68. $request->user = (object) ['id' => $operator === 'admin' ? 1 : 2, 'username' => $operator];
  69. $this->app->instance('request', $request);
  70. }
  71. public function test_websocket_events_cover_publication_boundaries_cancel_old_schedules_and_roll_back(): void
  72. {
  73. $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00', 'no_end' => false, 'end_at' => '2026-10-26 12:00:00']);
  74. $this->assertSame(['start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
  75. $this->assertSame(['2026-10-26 11:00:00', '2026-10-26 12:00:00'], DB::table('site_realtime_events')->orderBy('id')->pluck('available_at')->all());
  76. $this->service->status($row['id'], 0); $this->service->status($row['id'], 0);
  77. $this->assertSame([], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
  78. DB::beginTransaction(); $this->create(); DB::rollBack();
  79. $this->assertSame(0, DB::table('site_realtime_events')->count());
  80. }
  81. public function test_dual_internal_routes_save_list_edit_and_clear_independently_of_external_links(): void
  82. {
  83. $row = $this->create(['click_type' => 'internal', 'link_pc' => '/pc/news?id=7#info', 'link_h5' => '/h5/news?id=7#info']);
  84. $this->assertNull($row['link']);
  85. $this->assertSame('/pc/news?id=7#info', $row['link_pc']);
  86. $this->assertSame('/h5/news?id=7#info', $this->service->listing([])['data'][0]['link_h5']);
  87. $same = $this->service->save(['id' => $row['id'], 'status' => 1]);
  88. $this->assertEquals($row, $same);
  89. $this->assertSame(1, DB::table('operation_audits')->count());
  90. $edited = $this->service->save(['id' => $row['id'], 'link_h5' => '/mobile/news']);
  91. $this->assertSame($row['link_pc'], $edited['link_pc']);
  92. $this->assertSame('/mobile/news', $edited['link_h5']);
  93. $external = $this->service->save(['id' => $row['id'], 'click_type' => 'external', 'link' => 'https://example.com/news']);
  94. $this->assertSame('https://example.com/news', $external['link']);
  95. $this->assertNull($external['link_pc']); $this->assertNull($external['link_h5']);
  96. $plain = $this->service->save(['id' => $row['id'], 'click_type' => 'none']);
  97. foreach (['link', 'link_pc', 'link_h5'] as $field) $this->assertNull($plain[$field]);
  98. }
  99. public function test_invalid_or_missing_platform_routes_do_not_change_announcement_or_audit(): void
  100. {
  101. $row = $this->create(['click_type' => 'internal', 'link_pc' => '/pc', 'link_h5' => '/h5']);
  102. $before = DB::table('announcements')->get()->toJson();
  103. $logs = DB::table('operation_audits')->count();
  104. foreach (['link_pc', 'link_h5'] as $field) {
  105. foreach ([null, '', '//example.com', '/\\evil', 'https://example.com', "/a\nb", ['bad'], '/' . str_repeat('x', 2048)] as $value) {
  106. try { $this->service->save(['id' => $row['id'], $field => $value]); $this->fail('Invalid platform path accepted'); }
  107. catch (ValidationException $e) { $this->assertArrayHasKey($field, $e->errors()); }
  108. $this->assertSame($before, DB::table('announcements')->get()->toJson());
  109. $this->assertSame($logs, DB::table('operation_audits')->count());
  110. }
  111. }
  112. try { $this->create(['click_type' => 'internal', 'link_pc' => '/pc']); $this->fail('Missing H5 path accepted'); }
  113. catch (ValidationException $e) { $this->assertArrayHasKey('link_h5', $e->errors()); }
  114. }
  115. public function test_platform_link_migration_preserves_existing_shared_targets_in_both_tables(): void
  116. {
  117. $old = $this->create(['click_type' => 'internal', 'link' => '/shared']);
  118. $external = $this->create(['click_type' => 'external', 'link' => 'https://example.com']);
  119. DB::table('activity_rewards')->where('id', 1)->update(['click_type' => 'internal', 'link' => '/activity']);
  120. $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_30_120000_add_platform_links_to_announcements_and_activities.php';
  121. $migration->down();
  122. $before = [];
  123. foreach (['announcements', 'activity_rewards'] as $table) {
  124. $this->assertFalse(Schema::hasColumn($table, 'link_pc'));
  125. $before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
  126. }
  127. $migration->up();
  128. foreach (['announcements', 'activity_rewards'] as $table) {
  129. foreach (DB::table($table)->orderBy('id')->get() as $index => $record) {
  130. $row = (array) $record;
  131. $expected = $row['click_type'] === 'internal' ? $row['link'] : null;
  132. $this->assertSame($expected, $row['link_pc']); $this->assertSame($expected, $row['link_h5']);
  133. unset($row['link_pc'], $row['link_h5']);
  134. $this->assertSame((array) $before[$table][$index], $row);
  135. }
  136. }
  137. $this->assertSame('/shared', $this->service->detail($old['id'])['link_h5']);
  138. $this->assertNull($this->service->detail($external['id'])['link_h5']);
  139. }
  140. private function create(array $params = []): array
  141. {
  142. if (!array_key_exists('translations', $params)) $params['translations'] = ['zh' => ['title' => $params['title'] ?? '网站维护', 'content' => $params['content'] ?? '维护通知']];
  143. unset($params['title'], $params['content']);
  144. return $this->service->save($params);
  145. }
  146. public function test_options_defaults_create_sender_and_no_approval_state(): void
  147. {
  148. $options = $this->service->options();
  149. $this->assertSame(['none', 'external', 'internal'], array_column($options['click_types'], 'value'));
  150. $this->assertSame(['content', 'image', 'update'], array_column($options['categories'], 'value'));
  151. $this->assertSame(['PC', 'H5', 'APP'], array_column($options['platforms'], 'value'));
  152. $row = $this->create();
  153. $this->assertSame('active', $row['publication_state']); $this->assertSame(1, $row['effective_status']);
  154. $this->assertSame('2026-10-26 10:00:00', $row['publish_at']);
  155. $this->assertSame('全部', $row['platform_label']); $this->assertNull($row['end_at']);
  156. $this->assertSame('daily_first_login', $row['display_frequency']);
  157. $this->assertSame('批核者:admin', $row['sender_display']); $this->assertSame(1, $row['sender_id']);
  158. $this->assertSame(1, DB::table('operation_audits')->where('action', 'create')->count());
  159. $this->assertArrayNotHasKey('approval_status', $row);
  160. }
  161. public function test_three_categories_share_content_and_images_round_trip_without_text_html_rendering(): void
  162. {
  163. $literal = '<b>纯文本</b>\n请阅读';
  164. $row = $this->create(['content' => $literal]);
  165. $this->assertSame($literal, $row['translations']['zh']['content']);
  166. $image = $this->create(['category' => 'image', 'content' => 'https://admin.example.test/storage/images/example.png']);
  167. $this->assertSame('/storage/images/example.png', DB::table('announcement_translations')->where('announcement_id', $image['id'])->value('content'));
  168. $this->assertSame('https://admin.example.test/storage/images/example.png', $image['translations']['zh']['content']);
  169. $update = $this->create(['category' => 'update', 'content' => '新版内容']);
  170. $this->assertSame('新版内容', $update['translations']['zh']['content']);
  171. $this->expectException(ValidationException::class);
  172. $this->service->save(['id' => $row['id'], 'category' => 'image']);
  173. }
  174. public function test_scheduling_exact_boundaries_expiration_and_disabling_do_not_rewrite_stored_status(): void
  175. {
  176. $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00',
  177. 'no_end' => false, 'end_at' => '2026-10-26 12:00:00']);
  178. $this->assertSame('scheduled', $row['publication_state']);
  179. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  180. $this->assertSame('active', $this->service->detail($row['id'])['publication_state']);
  181. Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
  182. $this->assertSame('expired', $this->service->detail($row['id'])['publication_state']);
  183. $this->assertSame(1, DB::table('announcements')->value('status'));
  184. $this->assertSame('disabled', $this->service->status($row['id'], 0)['publication_state']);
  185. $this->assertSame('expired', $this->service->status($row['id'], 1)['publication_state']);
  186. }
  187. public function test_edit_preserves_original_sender_publish_time_and_omitted_fields_but_records_current_operator(): void
  188. {
  189. $row = $this->create(['click_type' => 'external', 'link' => 'https://example.com/a']);
  190. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  191. $this->request([], 'editor');
  192. $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']], 'publish_immediately' => true]);
  193. $this->assertSame('admin', $edited['sender_name']);
  194. $this->assertSame($row['publish_at'], $edited['publish_at']);
  195. $this->assertSame('https://example.com/a', $edited['link']);
  196. $logs = $this->service->logs(['operator_name' => 'editor', 'announcement_id' => $row['id']]);
  197. $this->assertSame(1, $logs['total']);
  198. $this->assertSame('维护通知', ((array) $logs['data'][0]['old_values'])['translations']['zh']['content']);
  199. $this->assertSame('修改内容', ((array) $logs['data'][0]['new_values'])['translations']['zh']['content']);
  200. $this->assertSame('所有人', $logs['data'][0]['recipient_label']);
  201. $count = DB::table('operation_audits')->count();
  202. $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']]]);
  203. $this->service->status($row['id'], 1);
  204. $this->assertSame($count, DB::table('operation_audits')->count());
  205. $scheduled = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-27 10:00:00']);
  206. $this->assertSame('2026-10-26 11:00:00', $this->service->save(['id' => $scheduled['id'], 'publish_immediately' => true])['publish_at']);
  207. }
  208. public function test_filters_apply_before_pagination_and_time_filter_is_publish_time(): void
  209. {
  210. $a = $this->create(['title' => '维护PC', 'publish_platforms' => ['PC'], 'status' => 0]);
  211. $this->create(['title' => '维护H5', 'publish_platforms' => ['H5'], 'category' => 'update']);
  212. $future = $this->create(['title' => '未来维护', 'publish_at' => '2026-10-27 02:00:00', 'publish_immediately' => false, 'status' => 0]);
  213. $filtered = $this->service->listing(['platform' => 'PC', 'status' => 0, 'title' => '维护', 'page' => 1, 'limit' => 1]);
  214. $this->assertSame(2, $filtered['total']); $this->assertSame($future['id'], $filtered['data'][0]['id']);
  215. $day = $this->service->listing(['start_date' => '2026-10-27', 'end_date' => '2026-10-27']);
  216. $this->assertSame(1, $day['total']); $this->assertSame($future['id'], $day['data'][0]['id']);
  217. $this->assertSame($a['id'], $this->service->listing(['platform' => 'PC', 'page' => 2, 'limit' => 1])['data'][0]['id']);
  218. $this->assertSame(1, $this->service->listing(['category' => 'update'])['total']);
  219. }
  220. /** @dataProvider invalidInputs */
  221. public function test_invalid_input_is_rejected_without_announcement_or_log_writes(array $values): void
  222. {
  223. try { $this->create($values); $this->fail('Expected invalid input'); }
  224. catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  225. $this->assertSame(0, DB::table('announcements')->count());
  226. $this->assertSame(0, DB::table('operation_audits')->count());
  227. }
  228. public static function invalidInputs(): array
  229. {
  230. return array_map(fn ($value) => [$value], [
  231. ['title' => ''], ['category' => 'other'], ['content' => ''], ['publish_platforms' => []],
  232. ['publish_platforms' => ['PC', 'PC']], ['publish_platforms' => ['IOS']], ['display_frequency' => 'always'],
  233. ['sender_name' => 'spoof'], ['sender_id' => 42], ['category' => 'image', 'content' => 'text'],
  234. ['category' => 'image', 'content' => '//evil.test/a.png'], ['category' => 'image', 'content' => '/a.svg'],
  235. ['publish_immediately' => false], ['publish_at' => '2026-02-30 10:00:00'], ['no_end' => false],
  236. ['end_at' => '2026-10-26 10:00:00'], ['status' => 2], ['click_type' => 'external', 'link' => 'javascript:alert(1)'],
  237. ['click_type' => 'external', 'link' => 'https://user:pass@example.com'], ['click_type' => 'internal', 'link' => '//example.com'],
  238. ['click_type' => 'internal', 'link' => '/\\evil'], ['click_type' => 'activity', 'activity_id' => 99],
  239. ]);
  240. }
  241. public function test_announcement_activity_linkage_is_removed_including_historical_links(): void
  242. {
  243. $row = $this->create(['click_type' => 'external', 'link' => 'https://example.com']);
  244. DB::table('announcements')->where('id', $row['id'])->update(['click_type' => 'activity', 'activity_id' => 1]);
  245. $legacy = $this->service->detail($row['id']);
  246. $this->assertSame('none', $legacy['click_type']); $this->assertNull($legacy['link']);
  247. $this->assertArrayNotHasKey('activity_id', $legacy);
  248. $row = $this->service->save(['id' => $row['id'], 'click_type' => 'internal', 'link' => '/news']);
  249. $this->assertArrayNotHasKey('activity_id', $row);
  250. $row = $this->service->save(['id' => $row['id'], 'click_type' => 'none']);
  251. $this->assertArrayNotHasKey('activity_id', $row); $this->assertNull($row['link']);
  252. Schema::drop('activity_rewards');
  253. $this->assertArrayNotHasKey('activity_id', $this->create());
  254. }
  255. public function test_normal_announcements_do_not_require_or_validate_a_hidden_activity_picker(): void
  256. {
  257. foreach (['none' => null, 'external' => 'https://example.com', 'internal' => '/news'] as $click => $link) {
  258. foreach ([null, 0, '', ['stale picker value']] as $activity) {
  259. $row = $this->create(['click_type' => $click, 'link' => $link, 'activity_id' => $activity]);
  260. $this->assertArrayNotHasKey('activity_id', $row); $this->assertSame($click, $row['click_type']);
  261. }
  262. }
  263. foreach ([null, 0, [], 'invalid'] as $activity) {
  264. try { $this->create(['click_type' => 'activity', 'activity_id' => $activity]); $this->fail('Invalid activity accepted'); }
  265. catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('click_type')); }
  266. }
  267. }
  268. public function test_missing_websocket_table_save_failure_is_atomic_and_migration_restores_saving(): void
  269. {
  270. $this->app->instance('log', new \Psr\Log\NullLogger());
  271. Schema::drop('site_realtime_events');
  272. $this->request(['click_type' => 'none', 'translations' => ['zh' => ['title' => '普通公告', 'content' => '公告正文']]]);
  273. $controller = new AnnouncementController();
  274. $failed = $controller->submit($this->service)->getData(true);
  275. $this->assertSame(-3, $failed['code']);
  276. $this->assertSame('实时通知服务未初始化,请先完成系统更新', $failed['msg']);
  277. $this->assertSame(0, DB::table('announcements')->count());
  278. $this->assertSame(0, DB::table('operation_audits')->count());
  279. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
  280. $saved = $controller->submit($this->service)->getData(true);
  281. $this->assertSame(0, $saved['code']); $this->assertArrayNotHasKey('activity_id', $saved['data']);
  282. }
  283. /** @dataProvider saveErrors */
  284. public function test_save_failures_report_the_reason_without_exposing_sql(string $state, int $code, string $expected): void
  285. {
  286. $this->app->instance('log', new \Psr\Log\NullLogger());
  287. $error = new \PDOException('SQLSTATE[' . $state . '] secret-value SELECT * FROM bot_announcements');
  288. $error->errorInfo = [$state, $code, 'secret-value'];
  289. $service = new class($error) extends AnnouncementService {
  290. private \Throwable $error;
  291. public function __construct(\Throwable $error) { $this->error = $error; }
  292. public function save(array $input): array { throw $this->error; }
  293. };
  294. $body = (new AnnouncementController())->submit($service)->getData(true);
  295. $this->assertSame(-3, $body['code']); $this->assertSame($expected, $body['msg']);
  296. $this->assertStringNotContainsString('secret-value', json_encode($body));
  297. $this->assertStringNotContainsString('SELECT', json_encode($body));
  298. }
  299. public static function saveErrors(): array
  300. {
  301. return [
  302. ['42S02', 1146, '公告数据未初始化,请先完成系统更新'],
  303. ['42S22', 1054, '公告字段结构未更新完整,请先完成系统更新'],
  304. ['HY000', 2002, '数据库连接失败,请检查数据库服务后重新保存'],
  305. ['22001', 1406, '公告内容超出存储长度,请缩短内容后保存'],
  306. ['40001', 1213, '公告数据正在被更新,请重新提交'],
  307. ['23000', 1062, '公告数据重复,请刷新页面后重新保存'],
  308. ['42000', 1064, '公告数据库操作失败(错误代码1064)'],
  309. ];
  310. }
  311. public function test_audit_failure_rolls_back_create_edit_and_status(): void
  312. {
  313. $row = $this->create();
  314. DB::statement("CREATE TRIGGER reject_ann_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
  315. foreach ([fn () => $this->create(), fn () => $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => 'bad', 'content' => '维护通知']]]), fn () => $this->service->status($row['id'], 0)] as $action) {
  316. try { $action(); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
  317. $this->assertSame(1, DB::table('announcements')->count());
  318. $this->assertSame('网站维护', DB::table('announcement_translations')->value('title'));
  319. $this->assertSame(1, DB::table('announcements')->value('status'));
  320. $this->assertSame(1, DB::table('operation_audits')->count());
  321. }
  322. }
  323. public function test_delete_removes_announcement_and_translations_and_cancels_notifications(): void
  324. {
  325. $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00']);
  326. $this->request(['id' => $row['id']]);
  327. $body = (new AnnouncementController())->delete($this->service)->getData(true);
  328. $this->assertSame(0, $body['code']);
  329. $this->assertSame(['id' => $row['id'], 'deleted' => true], $body['data']);
  330. $this->assertSame(0, DB::table('announcements')->count());
  331. $this->assertSame(0, DB::table('announcement_translations')->count());
  332. $this->assertSame(0, $this->service->listing([])['total']);
  333. $this->assertSame([], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
  334. $this->assertSame('delete', DB::table('operation_audits')->orderByDesc('id')->value('action'));
  335. $this->assertSame(-3, (new AnnouncementController())->delete($this->service)->getData(true)['code']);
  336. $this->assertSame(2, DB::table('operation_audits')->count());
  337. $this->assertSame(2, $this->service->logs([])['total']);
  338. }
  339. public function test_announcement_deletion_never_notifies_and_all_pending_events_are_cancelled(): void
  340. {
  341. $active = $this->create();
  342. $this->service->delete($active['id']);
  343. $this->assertSame([], DB::table('site_realtime_events')->pluck('action')->all());
  344. DB::table('site_realtime_events')->delete();
  345. $future = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00']);
  346. DB::table('site_realtime_events')->insert(['module' => 'announcement', 'resource_id' => $future['id'], 'action' => 'create',
  347. 'available_at' => '2026-10-26 10:00:00', 'created_at' => '2026-10-26 10:00:00']);
  348. $this->service->delete($future['id']);
  349. Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
  350. $this->assertSame(0, DB::table('site_realtime_events')->count());
  351. }
  352. public function test_delete_rolls_back_if_audit_or_notification_cannot_be_saved(): void
  353. {
  354. $row = $this->create();
  355. Schema::drop('site_realtime_events');
  356. try { $this->service->delete($row['id']); $this->fail('Missing event table must fail atomically'); }
  357. catch (ValidationException $e) { $this->assertArrayHasKey('realtime', $e->errors()); }
  358. $this->assertSame(1, DB::table('announcements')->count());
  359. $this->assertSame(1, DB::table('announcement_translations')->count());
  360. $this->assertSame(1, DB::table('operation_audits')->count());
  361. }
  362. public function test_controller_routes_are_authenticated_and_status_reuses_save_permission(): void
  363. {
  364. $router = new Router(new Dispatcher($this->app), $this->app);
  365. $this->app->instance('router', $router);
  366. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  367. foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) {
  368. $route = $router->getRoutes()->match(Request::create('/admin/announcement' . $suffix, $method));
  369. $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
  370. }
  371. $this->assertSame('admin/announcement/submit', $route->defaults['permission_uri']);
  372. $delete = $router->getRoutes()->match(Request::create('/admin/announcement/delete', 'POST'));
  373. $this->assertContains('admin.jwt', $delete->middleware());
  374. $this->assertContains('check.button.uri', $delete->middleware());
  375. $this->assertSame('delete', $delete->getActionMethod());
  376. $this->request(['translations' => ['zh' => ['title' => '测试', 'content' => '内容']]]);
  377. $result = (new AnnouncementController())->submit($this->service)->getData(true);
  378. $this->assertSame(0, $result['code']);
  379. $this->assertSame('admin', $result['data']['sender_name']);
  380. $this->request(['title' => '']);
  381. $this->assertSame(-3, (new AnnouncementController())->submit($this->service)->getData(true)['code']);
  382. }
  383. public function test_log_date_range_uses_business_time_and_historical_title_snapshot(): void
  384. {
  385. $row = $this->create();
  386. $this->request([], 'editor');
  387. Carbon::setTestNow(Carbon::parse('2026-10-27 00:10:00', 'Asia/Shanghai'));
  388. $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '修改后的标题', 'content' => '维护通知']]]);
  389. $old = $this->service->logs(['start_date' => '2026-10-26', 'end_date' => '2026-10-26']);
  390. $this->assertSame(1, $old['total']);
  391. $this->assertSame('网站维护', ((array) $old['data'][0]['titles'])['zh']);
  392. $this->assertSame('2026-10-26 10:00:00', $old['data'][0]['created_at']);
  393. $new = $this->service->logs(['operator_name' => 'edit', 'start_date' => '2026-10-27', 'end_date' => '2026-10-27', 'limit' => 1]);
  394. $this->assertSame(1, $new['total']); $this->assertSame('修改后的标题', ((array) $new['data'][0]['titles'])['zh']);
  395. $this->assertSame('2026-10-27 00:10:00', $new['data'][0]['created_at']);
  396. }
  397. public function test_end_time_can_be_cleared_and_partial_edits_preserve_existing_end_time(): void
  398. {
  399. $row = $this->create(['no_end' => false, 'end_at' => '2026-10-27 10:00:00']);
  400. $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '新标题', 'content' => '维护通知']]]);
  401. $this->assertSame($row['end_at'], $edited['end_at']);
  402. $cleared = $this->service->save(['id' => $row['id'], 'no_end' => true]);
  403. $this->assertNull($cleared['end_at']); $this->assertTrue($cleared['no_end']);
  404. $this->migration()->down();
  405. $this->assertFalse(Schema::hasTable('announcements'));
  406. $this->assertTrue(Schema::hasTable('operation_audits'));
  407. }
  408. public function test_languages_are_separate_rows_and_filters_count_a_parent_once_without_cross_language_title_matches(): void
  409. {
  410. $row = $this->create(['translations' => [
  411. 'zh' => ['title' => '中文标题', 'content' => '中文'], 'en' => ['title' => 'English title', 'content' => 'English'],
  412. 'vi' => ['title' => '', 'content' => ' '],
  413. ]]);
  414. $this->assertSame(2, DB::table('announcement_translations')->count());
  415. $this->assertSame(1, $this->service->listing([])['total']);
  416. $this->assertSame(1, $this->service->listing(['language' => 'en', 'title' => 'English'])['total']);
  417. $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']);
  418. $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']);
  419. $this->assertSame(['zh', 'en'], $row['languages']);
  420. $this->assertArrayNotHasKey('content', $this->service->listing([])['data'][0]);
  421. $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '中文标题', 'content' => '中文']]]);
  422. $this->assertSame(0, $this->service->listing(['language' => 'en'])['total']);
  423. $logs = $this->service->logs(['language' => 'en']);
  424. $this->assertSame(2, $logs['total']);
  425. $this->assertSame('English', ((array) $logs['data'][0]['old_values'])['translations']['en']['content']);
  426. $this->assertNull(((array) $logs['data'][0]['new_values'])['translations']);
  427. $this->assertSame('English title', ((array) $logs['data'][0]['titles'])['en']);
  428. $this->assertSame(0, $this->service->logs(['language' => 'vi'])['total']);
  429. }
  430. }