AnnouncementTest.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  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'); });
  54. DB::table('activity_rewards')->insert(['id' => 1, 'title' => '活动']);
  55. $this->migration()->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. private function create(array $params = []): array
  71. {
  72. if (!array_key_exists('translations', $params)) $params['translations'] = ['zh' => ['title' => $params['title'] ?? '网站维护', 'content' => $params['content'] ?? '维护通知']];
  73. unset($params['title'], $params['content']);
  74. return $this->service->save($params);
  75. }
  76. public function test_options_defaults_create_sender_and_no_approval_state(): void
  77. {
  78. $options = $this->service->options();
  79. $this->assertSame(['content', 'image', 'update'], array_column($options['categories'], 'value'));
  80. $this->assertSame(['PC', 'H5', 'APP'], array_column($options['platforms'], 'value'));
  81. $row = $this->create();
  82. $this->assertSame('active', $row['publication_state']); $this->assertSame(1, $row['effective_status']);
  83. $this->assertSame('2026-10-26 10:00:00', $row['publish_at']);
  84. $this->assertSame('全部', $row['platform_label']); $this->assertNull($row['end_at']);
  85. $this->assertSame('daily_first_login', $row['display_frequency']);
  86. $this->assertSame('批核者:admin', $row['sender_display']); $this->assertSame(1, $row['sender_id']);
  87. $this->assertSame(1, DB::table('operation_audits')->where('action', 'create')->count());
  88. $this->assertArrayNotHasKey('approval_status', $row);
  89. }
  90. public function test_three_categories_share_content_and_images_round_trip_without_text_html_rendering(): void
  91. {
  92. $literal = '<b>纯文本</b>\n请阅读';
  93. $row = $this->create(['content' => $literal]);
  94. $this->assertSame($literal, $row['translations']['zh']['content']);
  95. $image = $this->create(['category' => 'image', 'content' => 'https://admin.example.test/storage/images/example.png']);
  96. $this->assertSame('/storage/images/example.png', DB::table('announcement_translations')->where('announcement_id', $image['id'])->value('content'));
  97. $this->assertSame('https://admin.example.test/storage/images/example.png', $image['translations']['zh']['content']);
  98. $update = $this->create(['category' => 'update', 'content' => '新版内容']);
  99. $this->assertSame('新版内容', $update['translations']['zh']['content']);
  100. $this->expectException(ValidationException::class);
  101. $this->service->save(['id' => $row['id'], 'category' => 'image']);
  102. }
  103. public function test_scheduling_exact_boundaries_expiration_and_disabling_do_not_rewrite_stored_status(): void
  104. {
  105. $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00',
  106. 'no_end' => false, 'end_at' => '2026-10-26 12:00:00']);
  107. $this->assertSame('scheduled', $row['publication_state']);
  108. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  109. $this->assertSame('active', $this->service->detail($row['id'])['publication_state']);
  110. Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
  111. $this->assertSame('expired', $this->service->detail($row['id'])['publication_state']);
  112. $this->assertSame(1, DB::table('announcements')->value('status'));
  113. $this->assertSame('disabled', $this->service->status($row['id'], 0)['publication_state']);
  114. $this->assertSame('expired', $this->service->status($row['id'], 1)['publication_state']);
  115. }
  116. public function test_edit_preserves_original_sender_publish_time_and_omitted_fields_but_records_current_operator(): void
  117. {
  118. $row = $this->create(['click_type' => 'external', 'link' => 'https://example.com/a']);
  119. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  120. $this->request([], 'editor');
  121. $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']], 'publish_immediately' => true]);
  122. $this->assertSame('admin', $edited['sender_name']);
  123. $this->assertSame($row['publish_at'], $edited['publish_at']);
  124. $this->assertSame('https://example.com/a', $edited['link']);
  125. $logs = $this->service->logs(['operator_name' => 'editor', 'announcement_id' => $row['id']]);
  126. $this->assertSame(1, $logs['total']);
  127. $this->assertSame('维护通知', ((array) $logs['data'][0]['old_values'])['translations']['zh']['content']);
  128. $this->assertSame('修改内容', ((array) $logs['data'][0]['new_values'])['translations']['zh']['content']);
  129. $this->assertSame('所有人', $logs['data'][0]['recipient_label']);
  130. $count = DB::table('operation_audits')->count();
  131. $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']]]);
  132. $this->service->status($row['id'], 1);
  133. $this->assertSame($count, DB::table('operation_audits')->count());
  134. $scheduled = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-27 10:00:00']);
  135. $this->assertSame('2026-10-26 11:00:00', $this->service->save(['id' => $scheduled['id'], 'publish_immediately' => true])['publish_at']);
  136. }
  137. public function test_filters_apply_before_pagination_and_time_filter_is_publish_time(): void
  138. {
  139. $a = $this->create(['title' => '维护PC', 'publish_platforms' => ['PC'], 'status' => 0]);
  140. $this->create(['title' => '维护H5', 'publish_platforms' => ['H5'], 'category' => 'update']);
  141. $future = $this->create(['title' => '未来维护', 'publish_at' => '2026-10-27 02:00:00', 'publish_immediately' => false, 'status' => 0]);
  142. $filtered = $this->service->listing(['platform' => 'PC', 'status' => 0, 'title' => '维护', 'page' => 1, 'limit' => 1]);
  143. $this->assertSame(2, $filtered['total']); $this->assertSame($future['id'], $filtered['data'][0]['id']);
  144. $day = $this->service->listing(['start_date' => '2026-10-27', 'end_date' => '2026-10-27']);
  145. $this->assertSame(1, $day['total']); $this->assertSame($future['id'], $day['data'][0]['id']);
  146. $this->assertSame($a['id'], $this->service->listing(['platform' => 'PC', 'page' => 2, 'limit' => 1])['data'][0]['id']);
  147. $this->assertSame(1, $this->service->listing(['category' => 'update'])['total']);
  148. }
  149. /** @dataProvider invalidInputs */
  150. public function test_invalid_input_is_rejected_without_announcement_or_log_writes(array $values): void
  151. {
  152. try { $this->create($values); $this->fail('Expected invalid input'); }
  153. catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  154. $this->assertSame(0, DB::table('announcements')->count());
  155. $this->assertSame(0, DB::table('operation_audits')->count());
  156. }
  157. public static function invalidInputs(): array
  158. {
  159. return array_map(fn ($value) => [$value], [
  160. ['title' => ''], ['category' => 'other'], ['content' => ''], ['publish_platforms' => []],
  161. ['publish_platforms' => ['PC', 'PC']], ['publish_platforms' => ['IOS']], ['display_frequency' => 'always'],
  162. ['sender_name' => 'spoof'], ['sender_id' => 42], ['category' => 'image', 'content' => 'text'],
  163. ['category' => 'image', 'content' => '//evil.test/a.png'], ['category' => 'image', 'content' => '/a.svg'],
  164. ['publish_immediately' => false], ['publish_at' => '2026-02-30 10:00:00'], ['no_end' => false],
  165. ['end_at' => '2026-10-26 10:00:00'], ['status' => 2], ['click_type' => 'external', 'link' => 'javascript:alert(1)'],
  166. ['click_type' => 'external', 'link' => 'https://user:pass@example.com'], ['click_type' => 'internal', 'link' => '//example.com'],
  167. ['click_type' => 'internal', 'link' => '/\\evil'], ['click_type' => 'activity', 'activity_id' => 99],
  168. ]);
  169. }
  170. public function test_click_targets_use_explicit_types_and_stale_targets_are_cleared(): void
  171. {
  172. $row = $this->create(['click_type' => 'activity', 'activity_id' => 1]);
  173. $this->assertSame(1, $row['activity_id']); $this->assertNull($row['link']);
  174. $row = $this->service->save(['id' => $row['id'], 'click_type' => 'internal', 'link' => '/activity/1']);
  175. $this->assertNull($row['activity_id']);
  176. $row = $this->service->save(['id' => $row['id'], 'click_type' => 'none']);
  177. $this->assertNull($row['activity_id']); $this->assertNull($row['link']);
  178. }
  179. public function test_audit_failure_rolls_back_create_edit_and_status(): void
  180. {
  181. $row = $this->create();
  182. DB::statement("CREATE TRIGGER reject_ann_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
  183. 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) {
  184. try { $action(); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
  185. $this->assertSame(1, DB::table('announcements')->count());
  186. $this->assertSame('网站维护', DB::table('announcement_translations')->value('title'));
  187. $this->assertSame(1, DB::table('announcements')->value('status'));
  188. $this->assertSame(1, DB::table('operation_audits')->count());
  189. }
  190. }
  191. public function test_controller_routes_are_authenticated_and_status_reuses_save_permission(): void
  192. {
  193. $router = new Router(new Dispatcher($this->app), $this->app);
  194. $this->app->instance('router', $router);
  195. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  196. foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) {
  197. $route = $router->getRoutes()->match(Request::create('/admin/announcement' . $suffix, $method));
  198. $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
  199. }
  200. $this->assertSame('admin/announcement/submit', $route->defaults['permission_uri']);
  201. $this->request(['translations' => ['zh' => ['title' => '测试', 'content' => '内容']]]);
  202. $result = (new AnnouncementController())->submit($this->service)->getData(true);
  203. $this->assertSame(0, $result['code']);
  204. $this->assertSame('admin', $result['data']['sender_name']);
  205. $this->request(['title' => '']);
  206. $this->assertSame(-3, (new AnnouncementController())->submit($this->service)->getData(true)['code']);
  207. }
  208. public function test_log_date_range_uses_business_time_and_historical_title_snapshot(): void
  209. {
  210. $row = $this->create();
  211. $this->request([], 'editor');
  212. Carbon::setTestNow(Carbon::parse('2026-10-27 00:10:00', 'Asia/Shanghai'));
  213. $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '修改后的标题', 'content' => '维护通知']]]);
  214. $old = $this->service->logs(['start_date' => '2026-10-26', 'end_date' => '2026-10-26']);
  215. $this->assertSame(1, $old['total']);
  216. $this->assertSame('网站维护', ((array) $old['data'][0]['titles'])['zh']);
  217. $this->assertSame('2026-10-26 10:00:00', $old['data'][0]['created_at']);
  218. $new = $this->service->logs(['operator_name' => 'edit', 'start_date' => '2026-10-27', 'end_date' => '2026-10-27', 'limit' => 1]);
  219. $this->assertSame(1, $new['total']); $this->assertSame('修改后的标题', ((array) $new['data'][0]['titles'])['zh']);
  220. $this->assertSame('2026-10-27 00:10:00', $new['data'][0]['created_at']);
  221. }
  222. public function test_end_time_can_be_cleared_and_partial_edits_preserve_existing_end_time(): void
  223. {
  224. $row = $this->create(['no_end' => false, 'end_at' => '2026-10-27 10:00:00']);
  225. $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '新标题', 'content' => '维护通知']]]);
  226. $this->assertSame($row['end_at'], $edited['end_at']);
  227. $cleared = $this->service->save(['id' => $row['id'], 'no_end' => true]);
  228. $this->assertNull($cleared['end_at']); $this->assertTrue($cleared['no_end']);
  229. $this->migration()->down();
  230. $this->assertFalse(Schema::hasTable('announcements'));
  231. $this->assertTrue(Schema::hasTable('operation_audits'));
  232. }
  233. public function test_languages_are_separate_rows_and_filters_count_a_parent_once_without_cross_language_title_matches(): void
  234. {
  235. $row = $this->create(['translations' => [
  236. 'zh' => ['title' => '中文标题', 'content' => '中文'], 'en' => ['title' => 'English title', 'content' => 'English'],
  237. 'vi' => ['title' => '', 'content' => ' '],
  238. ]]);
  239. $this->assertSame(2, DB::table('announcement_translations')->count());
  240. $this->assertSame(1, $this->service->listing([])['total']);
  241. $this->assertSame(1, $this->service->listing(['language' => 'en', 'title' => 'English'])['total']);
  242. $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']);
  243. $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']);
  244. $this->assertSame(['zh', 'en'], $row['languages']);
  245. $this->assertArrayNotHasKey('content', $this->service->listing([])['data'][0]);
  246. $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '中文标题', 'content' => '中文']]]);
  247. $this->assertSame(0, $this->service->listing(['language' => 'en'])['total']);
  248. $logs = $this->service->logs(['language' => 'en']);
  249. $this->assertSame(2, $logs['total']);
  250. $this->assertSame('English', ((array) $logs['data'][0]['old_values'])['translations']['en']['content']);
  251. $this->assertNull(((array) $logs['data'][0]['new_values'])['translations']);
  252. $this->assertSame('English title', ((array) $logs['data'][0]['titles'])['en']);
  253. $this->assertSame(0, $this->service->logs(['language' => 'vi'])['total']);
  254. }
  255. }