| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- <?php
- namespace Tests\Integration;
- use App\Http\Controllers\admin\Announcement as AnnouncementController;
- use App\Services\AnnouncementService;
- use App\Support\AnnouncementCatalog;
- use Carbon\Carbon;
- use Illuminate\Config\Repository;
- use Illuminate\Database\Capsule\Manager;
- use Illuminate\Database\Schema\Blueprint;
- use Illuminate\Events\Dispatcher;
- use Illuminate\Foundation\Application;
- use Illuminate\Http\JsonResponse;
- use Illuminate\Http\Request;
- use Illuminate\Routing\Router;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Facade;
- use Illuminate\Support\Facades\Schema;
- use Illuminate\Translation\ArrayLoader;
- use Illuminate\Translation\Translator;
- use Illuminate\Validation\Factory;
- use Illuminate\Validation\ValidationException;
- use PHPUnit\Framework\TestCase;
- /** Actual admin controller, migration, publication and audit queries, with isolated SQLite only. */
- class AnnouncementTest extends TestCase
- {
- private Application $app;
- private AnnouncementService $service;
- protected function setUp(): void
- {
- parent::setUp();
- Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
- Facade::clearResolvedInstances();
- $this->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(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->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);
- }
- public function test_websocket_events_cover_publication_boundaries_cancel_old_schedules_and_roll_back(): 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(['create', 'start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
- $this->assertSame(['2026-10-26 10:00:00', '2026-10-26 11:00:00', '2026-10-26 12:00:00'], DB::table('site_realtime_events')->orderBy('id')->pluck('available_at')->all());
- $this->service->status($row['id'], 0); $this->service->status($row['id'], 0);
- $this->assertSame(['create', 'status'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
- DB::beginTransaction(); $this->create(); DB::rollBack();
- $this->assertSame(2, DB::table('site_realtime_events')->count());
- }
- 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 = '<b>纯文本</b>\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']);
- }
- }
|