app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai', 'url' => 'https://admin.example.test']])); Facade::setFacadeApplication($this->app); $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema()); $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class { public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); } }); Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); }); Schema::create('operation_audits', function (Blueprint $t) { $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes'); $t->integer('operator_id'); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent(); }); Schema::create('activity_rewards', function (Blueprint $t) { $t->id(); $t->string('title'); }); DB::table('activity_rewards')->insert(['id' => 1, 'title' => '活动']); $this->migration()->up(); $this->service = new AnnouncementService(); $this->request(); } protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_100000_create_announcements.php'; } private function request(array $params = [], string $operator = 'admin'): void { $request = Request::create('/admin/announcement/submit', 'POST', $params); $request->user = (object) ['id' => $operator === 'admin' ? 1 : 2, 'username' => $operator]; $this->app->instance('request', $request); } private function create(array $params = []): array { if (!array_key_exists('translations', $params)) $params['translations'] = ['zh' => ['title' => $params['title'] ?? '网站维护', 'content' => $params['content'] ?? '维护通知']]; unset($params['title'], $params['content']); return $this->service->save($params); } public function test_options_defaults_create_sender_and_no_approval_state(): void { $options = $this->service->options(); $this->assertSame(['content', 'image', 'update'], array_column($options['categories'], 'value')); $this->assertSame(['PC', 'H5', 'APP'], array_column($options['platforms'], 'value')); $row = $this->create(); $this->assertSame('active', $row['publication_state']); $this->assertSame(1, $row['effective_status']); $this->assertSame('2026-10-26 10:00:00', $row['publish_at']); $this->assertSame('全部', $row['platform_label']); $this->assertNull($row['end_at']); $this->assertSame('daily_first_login', $row['display_frequency']); $this->assertSame('批核者:admin', $row['sender_display']); $this->assertSame(1, $row['sender_id']); $this->assertSame(1, DB::table('operation_audits')->where('action', 'create')->count()); $this->assertArrayNotHasKey('approval_status', $row); } public function test_three_categories_share_content_and_images_round_trip_without_text_html_rendering(): void { $literal = '纯文本\n请阅读'; $row = $this->create(['content' => $literal]); $this->assertSame($literal, $row['translations']['zh']['content']); $image = $this->create(['category' => 'image', 'content' => 'https://admin.example.test/storage/images/example.png']); $this->assertSame('/storage/images/example.png', DB::table('announcement_translations')->where('announcement_id', $image['id'])->value('content')); $this->assertSame('https://admin.example.test/storage/images/example.png', $image['translations']['zh']['content']); $update = $this->create(['category' => 'update', 'content' => '新版内容']); $this->assertSame('新版内容', $update['translations']['zh']['content']); $this->expectException(ValidationException::class); $this->service->save(['id' => $row['id'], 'category' => 'image']); } public function test_scheduling_exact_boundaries_expiration_and_disabling_do_not_rewrite_stored_status(): void { $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']); $this->assertSame('scheduled', $row['publication_state']); Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->assertSame('active', $this->service->detail($row['id'])['publication_state']); Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai')); $this->assertSame('expired', $this->service->detail($row['id'])['publication_state']); $this->assertSame(1, DB::table('announcements')->value('status')); $this->assertSame('disabled', $this->service->status($row['id'], 0)['publication_state']); $this->assertSame('expired', $this->service->status($row['id'], 1)['publication_state']); } public function test_edit_preserves_original_sender_publish_time_and_omitted_fields_but_records_current_operator(): void { $row = $this->create(['click_type' => 'external', 'link' => 'https://example.com/a']); Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->request([], 'editor'); $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']], 'publish_immediately' => true]); $this->assertSame('admin', $edited['sender_name']); $this->assertSame($row['publish_at'], $edited['publish_at']); $this->assertSame('https://example.com/a', $edited['link']); $logs = $this->service->logs(['operator_name' => 'editor', 'announcement_id' => $row['id']]); $this->assertSame(1, $logs['total']); $this->assertSame('维护通知', ((array) $logs['data'][0]['old_values'])['translations']['zh']['content']); $this->assertSame('修改内容', ((array) $logs['data'][0]['new_values'])['translations']['zh']['content']); $this->assertSame('所有人', $logs['data'][0]['recipient_label']); $count = DB::table('operation_audits')->count(); $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']]]); $this->service->status($row['id'], 1); $this->assertSame($count, DB::table('operation_audits')->count()); $scheduled = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-27 10:00:00']); $this->assertSame('2026-10-26 11:00:00', $this->service->save(['id' => $scheduled['id'], 'publish_immediately' => true])['publish_at']); } public function test_filters_apply_before_pagination_and_time_filter_is_publish_time(): void { $a = $this->create(['title' => '维护PC', 'publish_platforms' => ['PC'], 'status' => 0]); $this->create(['title' => '维护H5', 'publish_platforms' => ['H5'], 'category' => 'update']); $future = $this->create(['title' => '未来维护', 'publish_at' => '2026-10-27 02:00:00', 'publish_immediately' => false, 'status' => 0]); $filtered = $this->service->listing(['platform' => 'PC', 'status' => 0, 'title' => '维护', 'page' => 1, 'limit' => 1]); $this->assertSame(2, $filtered['total']); $this->assertSame($future['id'], $filtered['data'][0]['id']); $day = $this->service->listing(['start_date' => '2026-10-27', 'end_date' => '2026-10-27']); $this->assertSame(1, $day['total']); $this->assertSame($future['id'], $day['data'][0]['id']); $this->assertSame($a['id'], $this->service->listing(['platform' => 'PC', 'page' => 2, 'limit' => 1])['data'][0]['id']); $this->assertSame(1, $this->service->listing(['category' => 'update'])['total']); } /** @dataProvider invalidInputs */ public function test_invalid_input_is_rejected_without_announcement_or_log_writes(array $values): void { try { $this->create($values); $this->fail('Expected invalid input'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); } $this->assertSame(0, DB::table('announcements')->count()); $this->assertSame(0, DB::table('operation_audits')->count()); } public static function invalidInputs(): array { return array_map(fn ($value) => [$value], [ ['title' => ''], ['category' => 'other'], ['content' => ''], ['publish_platforms' => []], ['publish_platforms' => ['PC', 'PC']], ['publish_platforms' => ['IOS']], ['display_frequency' => 'always'], ['sender_name' => 'spoof'], ['sender_id' => 42], ['category' => 'image', 'content' => 'text'], ['category' => 'image', 'content' => '//evil.test/a.png'], ['category' => 'image', 'content' => '/a.svg'], ['publish_immediately' => false], ['publish_at' => '2026-02-30 10:00:00'], ['no_end' => false], ['end_at' => '2026-10-26 10:00:00'], ['status' => 2], ['click_type' => 'external', 'link' => 'javascript:alert(1)'], ['click_type' => 'external', 'link' => 'https://user:pass@example.com'], ['click_type' => 'internal', 'link' => '//example.com'], ['click_type' => 'internal', 'link' => '/\\evil'], ['click_type' => 'activity', 'activity_id' => 99], ]); } public function test_click_targets_use_explicit_types_and_stale_targets_are_cleared(): void { $row = $this->create(['click_type' => 'activity', 'activity_id' => 1]); $this->assertSame(1, $row['activity_id']); $this->assertNull($row['link']); $row = $this->service->save(['id' => $row['id'], 'click_type' => 'internal', 'link' => '/activity/1']); $this->assertNull($row['activity_id']); $row = $this->service->save(['id' => $row['id'], 'click_type' => 'none']); $this->assertNull($row['activity_id']); $this->assertNull($row['link']); } public function test_audit_failure_rolls_back_create_edit_and_status(): void { $row = $this->create(); DB::statement("CREATE TRIGGER reject_ann_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END"); 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) { try { $action(); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); } $this->assertSame(1, DB::table('announcements')->count()); $this->assertSame('网站维护', DB::table('announcement_translations')->value('title')); $this->assertSame(1, DB::table('announcements')->value('status')); $this->assertSame(1, DB::table('operation_audits')->count()); } } public function test_controller_routes_are_authenticated_and_status_reuses_save_permission(): void { $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router); $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; }); foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) { $route = $router->getRoutes()->match(Request::create('/admin/announcement' . $suffix, $method)); $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware()); } $this->assertSame('admin/announcement/submit', $route->defaults['permission_uri']); $this->request(['translations' => ['zh' => ['title' => '测试', 'content' => '内容']]]); $result = (new AnnouncementController())->submit($this->service)->getData(true); $this->assertSame(0, $result['code']); $this->assertSame('admin', $result['data']['sender_name']); $this->request(['title' => '']); $this->assertSame(-3, (new AnnouncementController())->submit($this->service)->getData(true)['code']); } public function test_log_date_range_uses_business_time_and_historical_title_snapshot(): void { $row = $this->create(); $this->request([], 'editor'); Carbon::setTestNow(Carbon::parse('2026-10-27 00:10:00', 'Asia/Shanghai')); $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '修改后的标题', 'content' => '维护通知']]]); $old = $this->service->logs(['start_date' => '2026-10-26', 'end_date' => '2026-10-26']); $this->assertSame(1, $old['total']); $this->assertSame('网站维护', ((array) $old['data'][0]['titles'])['zh']); $this->assertSame('2026-10-26 10:00:00', $old['data'][0]['created_at']); $new = $this->service->logs(['operator_name' => 'edit', 'start_date' => '2026-10-27', 'end_date' => '2026-10-27', 'limit' => 1]); $this->assertSame(1, $new['total']); $this->assertSame('修改后的标题', ((array) $new['data'][0]['titles'])['zh']); $this->assertSame('2026-10-27 00:10:00', $new['data'][0]['created_at']); } public function test_end_time_can_be_cleared_and_partial_edits_preserve_existing_end_time(): void { $row = $this->create(['no_end' => false, 'end_at' => '2026-10-27 10:00:00']); $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '新标题', 'content' => '维护通知']]]); $this->assertSame($row['end_at'], $edited['end_at']); $cleared = $this->service->save(['id' => $row['id'], 'no_end' => true]); $this->assertNull($cleared['end_at']); $this->assertTrue($cleared['no_end']); $this->migration()->down(); $this->assertFalse(Schema::hasTable('announcements')); $this->assertTrue(Schema::hasTable('operation_audits')); } public function test_languages_are_separate_rows_and_filters_count_a_parent_once_without_cross_language_title_matches(): void { $row = $this->create(['translations' => [ 'zh' => ['title' => '中文标题', 'content' => '中文'], 'en' => ['title' => 'English title', 'content' => 'English'], 'vi' => ['title' => '', 'content' => ' '], ]]); $this->assertSame(2, DB::table('announcement_translations')->count()); $this->assertSame(1, $this->service->listing([])['total']); $this->assertSame(1, $this->service->listing(['language' => 'en', 'title' => 'English'])['total']); $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']); $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']); $this->assertSame(['zh', 'en'], $row['languages']); $this->assertArrayNotHasKey('content', $this->service->listing([])['data'][0]); $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '中文标题', 'content' => '中文']]]); $this->assertSame(0, $this->service->listing(['language' => 'en'])['total']); $logs = $this->service->logs(['language' => 'en']); $this->assertSame(2, $logs['total']); $this->assertSame('English', ((array) $logs['data'][0]['old_values'])['translations']['en']['content']); $this->assertNull(((array) $logs['data'][0]['new_values'])['translations']); $this->assertSame('English title', ((array) $logs['data'][0]['titles'])['en']); $this->assertSame(0, $this->service->logs(['language' => 'vi'])['total']); } }