AnnouncementTest.php 30 KB

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