app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'agent' => ['admin_ids' => [1]]])); 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('users', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('member_id'); $t->string('account'); $t->string('username'); $t->string('first_name'); $t->string('language')->nullable(); $t->integer('from'); $t->dateTime('delete_time')->nullable(); $t->timestamps(); }); Schema::create('agents', function (Blueprint $t) { $t->id(); $t->string('username'); $t->string('real_name'); $t->integer('status')->default(1); $t->timestamps(); }); 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')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent(); }); $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up(); foreach ([1 => ['zh', 1], 2 => ['en', 1], 3 => ['vi', 1], 4 => [null, 1], 5 => ['zh', -1], 6 => ['zh', 2], 7 => ['zh', 0], 8 => ['zh', 1]] as $id => [$lang, $from]) { DB::table('users')->insert(['id' => $id, 'user_id' => (string) (100 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id, 'username' => 'name' . $id, 'first_name' => '昵称' . $id, 'language' => $lang, 'from' => $from, 'delete_time' => $id === 8 ? now() : null]); } foreach ([1 => 'zh', 2 => 'en', 3 => 'vi'] as $id => $lang) DB::table('agents')->insert(['id' => $id, 'username' => 'agent' . $id, 'real_name' => '姓名' . $id, 'language' => $lang, 'status' => $id === 3 ? 0 : 1]); $this->service = new SiteMessageService(); $this->inbox = new SiteInboxService(); $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_110000_create_site_messages.php'; } private function request(array $params = [], int $admin = 1): Request { $request = Request::create('/admin/message/send', 'POST', $params); $request->user = (object) ['id' => $admin, 'username' => 'admin' . $admin]; $this->app->instance('request', $request); return $request; } private function send(array $extra = []): array { return $this->service->send($extra + ['request_id' => 'fixture-' . ++$this->sequence, 'recipient_type' => 'all_users', 'translations' => ['zh' => ['title' => '中文标题', 'subtitle' => '副标题', 'content' => '中文正文'], 'en' => ['title' => 'English', 'content' => 'English body'], 'vi' => ['content' => '']]]); } public function test_language_specific_delivery_is_real_frontend_only_and_retries_are_idempotent(): void { $a = $this->send(['request_id' => 'same']); $b = $this->send(['request_id' => 'same']); $this->assertSame($a['id'], $b['id']); $this->assertSame('sent', $a['status']); $this->assertSame(2, $a['recipient_count']); $this->assertSame(2, $a['skipped_count']); $this->assertSame(2, DB::table('site_message_translations')->count()); $this->assertSame([1, 2], DB::table('site_message_recipients')->orderBy('recipient_id')->pluck('recipient_id')->all()); $this->assertSame('中文正文', $this->inbox->detail('user', 1, $a['id'])['content']); $this->assertSame('English body', $this->inbox->detail('user', 2, $a['id'])['content']); $this->assertSame(0, $this->inbox->unread('user', 3)['count']); $this->assertSame(0, $this->inbox->unread('agent', 1)['count']); $this->assertSame(1, DB::table('operation_audits')->count()); $this->expectException(ValidationException::class); $this->send(['request_id' => 'same', 'category' => 'promotion']); } public function test_websocket_events_are_committed_with_delivery_and_retraction_and_retries_do_not_duplicate(): void { $message = $this->send(['request_id' => 'push-once']); $this->send(['request_id' => 'push-once']); $this->assertSame(1, DB::table('site_realtime_events')->count()); $this->assertSame('message', DB::table('site_realtime_events')->first()->module); $this->assertSame($message['id'], (int) DB::table('site_realtime_events')->first()->resource_id); $this->service->retract($message['id']); $this->service->retract($message['id']); $this->assertSame(['send', 'retract'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all()); $this->send(['send_at' => '2026-10-26 11:00:00']); $this->assertSame(2, DB::table('site_realtime_events')->count()); Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->service->dispatchDue(); $this->assertSame(3, DB::table('site_realtime_events')->count()); DB::beginTransaction(); $this->send(); DB::rollBack(); $this->assertSame(3, DB::table('site_realtime_events')->count()); } public function test_specified_users_and_agents_use_distinct_id_namespaces_and_retraction_hides_every_read_path(): void { $user = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [1, 2, 1], 'request_id' => 'select']); $again = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [2, 1], 'request_id' => 'select']); $this->assertSame($user['id'], $again['id']); $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1, 3], 'translations' => ['zh' => ['title' => '代理', 'content' => '仅代理本人']]]); $this->assertSame(1, $agent['recipient_count']); $this->assertSame('仅代理本人', $this->inbox->detail('agent', 1, $agent['id'])['content']); try { $this->inbox->detail('user', 1, $agent['id']); $this->fail('Cross-kind access'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); } $this->assertSame(1, $this->inbox->unread('agent', 1)['count']); $this->service->retract($agent['id']); $logs = DB::table('operation_audits')->count(); $this->service->retract($agent['id']); $this->assertSame($logs, DB::table('operation_audits')->count()); $this->assertSame(0, $this->inbox->listing('agent', 1, [])['count']); $this->assertSame(0, $this->inbox->unread('agent', 1)['count']); foreach ([fn () => $this->inbox->detail('agent', 1, $agent['id']), fn () => $this->inbox->read('agent', 1, $agent['id'])] as $call) { try { $call(); $this->fail('Retracted read'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); } } $this->assertSame('retracted', $this->service->detail($agent['id'])['status']); $this->assertSame(2, $this->service->listing([])['total']); } public function test_read_is_explicit_owned_and_preserves_first_read_time_and_delivery_language(): void { $message = $this->send(); $id = $message['id']; $this->inbox->detail('user', 1, $id); $this->assertSame(1, $this->inbox->unread('user', 1)['count']); $first = $this->inbox->read('user', 1, $id); Carbon::setTestNow(now()->addHour()); $this->assertSame($first, $this->inbox->read('user', 1, $id)); $this->assertSame(0, $this->inbox->listing('user', 1, ['is_read' => 0])['count']); $this->assertSame(1, $this->inbox->listing('user', 1, ['is_read' => 1])['count']); DB::table('users')->where('id', 1)->update(['language' => 'vi']); $this->assertSame('zh', $this->inbox->detail('user', 1, $id)['language']); $this->assertSame('中文正文', $this->inbox->detail('user', 1, $id)['content']); $this->expectException(ValidationException::class); $this->inbox->read('user', 3, $id); } public function test_scheduled_delivery_snapshots_accounts_and_language_at_actual_send_and_can_be_cancelled(): void { $message = $this->send(['send_at' => '2026-10-26 11:00:00']); $cancel = $this->send(['send_at' => '2026-10-26 11:00:00']); $this->service->retract($cancel['id']); $this->assertSame('scheduled', $message['status']); $this->assertSame(0, DB::table('site_message_recipients')->count()); DB::table('users')->where('id', 2)->update(['language' => 'vi']); DB::table('users')->insert(['id' => 9, 'user_id' => '109', 'member_id' => 'M9', 'account' => 'late', 'username' => '', 'first_name' => 'later', 'language' => 'zh', 'from' => 1]); $this->assertSame(0, $this->service->dispatchDue()['sent']); Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->assertSame(1, $this->service->dispatchDue()['sent']); $this->assertSame([1, 9], DB::table('site_message_recipients')->orderBy('recipient_id')->pluck('recipient_id')->all()); DB::table('users')->insert(['id' => 10, 'user_id' => '110', 'member_id' => 'M10', 'account' => 'after', 'username' => '', 'first_name' => 'after', 'language' => 'zh', 'from' => 1]); $this->assertSame(0, $this->service->dispatchDue()['sent']); $this->assertSame(0, $this->inbox->unread('user', 10)['count']); $this->assertSame('系统', DB::table('operation_audits')->where('resource_id', $message['id'])->where('action', 'send')->value('operator_name')); } public function test_lightweight_search_language_filters_and_agent_permissions_do_not_leak_finance_or_other_kinds(): void { $users = $this->service->recipients('user', ['q' => 'member', 'limit' => 2]); $this->assertSame(4, $users['total']); $this->assertCount(2, $users['data']); $this->assertSame(['id', 'user_id', 'member_id', 'account', 'username', 'first_name', 'language'], array_keys((array) $users['data'][0])); $this->assertSame(2, $this->service->recipients('user', ['q' => '102'])['data'][0]->id); $this->send(); $this->send(['recipient_type' => 'all_agents']); $this->assertSame(2, $this->service->listing(['language' => 'en'])['total']); $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']); $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']); $this->assertSame(2, $this->service->logs(['language' => 'en'])['total']); $this->request([], 2); $this->assertSame(1, $this->service->listing([])['total']); $this->assertSame(1, $this->service->logs([])['total']); foreach ([fn () => $this->service->recipients('agent', []), fn () => $this->send(['recipient_type' => 'all_agents'])] as $call) { try { $call(); $this->fail('Agent management bypass'); } catch (AuthorizationException $e) { $this->assertNotEmpty($e->getMessage()); } } } public function test_delivery_and_audit_failures_roll_back_everything_and_retry_once(): void { DB::statement("CREATE TRIGGER reject_receipts BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'delivery unavailable'); END"); try { $this->send(); $this->fail('Expected failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('delivery unavailable', $e->getMessage()); } $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('site_message_translations')->count()); DB::statement('DROP TRIGGER reject_receipts'); $message = $this->send(['send_at' => '2026-10-26 11:00:00']); DB::statement("CREATE TRIGGER reject_message_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END"); Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->assertSame(1, $this->service->dispatchDue()['failed']); $this->assertSame('scheduled', $this->service->detail($message['id'])['status']); $this->assertSame(0, DB::table('site_message_recipients')->count()); DB::statement('DROP TRIGGER reject_message_audit'); $this->assertSame(1, $this->service->dispatchDue()['sent']); $this->assertSame(2, DB::table('site_message_recipients')->count()); } /** @dataProvider invalidInputs */ public function test_invalid_messages_never_leave_partial_rows(array $input): void { try { $this->send($input); $this->fail('Expected validation failure'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); } $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('operation_audits')->count()); } public static function invalidInputs(): array { return array_map(fn ($x) => [$x], [['translations' => []], ['translations' => ['zh' => ['content' => '']]], ['translations' => ['de' => ['title' => 'x', 'content' => 'x']]], ['translations' => ['zh' => ['title' => '', 'content' => 'x']]], ['translations' => ['en' => ['title' => str_repeat('a', 501), 'content' => 'x']]], ['translations' => ['vi' => ['title' => 'x', 'content' => str_repeat('a', 5001)]]], ['recipient_type' => 'specified_users', 'recipient_ids' => []], ['recipient_type' => 'specified_users', 'recipient_ids' => [5]], ['recipient_type' => 'specified_users', 'recipient_ids' => [8]], ['recipient_type' => 'all_users', 'recipient_ids' => [1]], ['send_at' => '2026-02-30 10:00:00'], ['sender_id' => 99], ['request_id' => ''], ['category' => 'other']]); } public function test_agent_language_only_profile_update_keeps_real_name_and_all_new_routes_use_correct_auth(): void { DB::table('agents')->insert(['id' => 4, 'username' => 'new', 'real_name' => '保留姓名']); $this->assertSame('zh', DB::table('agents')->where('id', 4)->value('language')); $request = $this->request(['language' => 'en']); $request->attributes->set('agent', Agent::findOrFail(4)); $this->assertSame(0, (new ProfileController())->update($request)->getData(true)['code']); $this->assertSame('保留姓名', DB::table('agents')->where('id', 4)->value('real_name')); $this->assertSame('en', DB::table('agents')->where('id', 4)->value('language')); $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'; }); $router->group(['prefix' => 'agent'], function () { require dirname(__DIR__, 2) . '/routes/agent.php'; }); foreach (['/admin/message', '/admin/message/recipients/users', '/admin/message/recipients/agents', '/admin/message/logs'] as $uri) { $route = $router->getRoutes()->match(Request::create($uri)); $this->assertContains('admin.jwt', $route->middleware()); } $this->assertContains('agent.auth', $router->getRoutes()->match(Request::create('/agent/messages'))->middleware()); $this->assertContains('agent.admin', $router->getRoutes()->match(Request::create('/admin/message/recipients/agents'))->middleware()); $this->request(['translations' => []]); $this->assertSame(-3, (new SiteMessage())->send($this->service)->getData(true)['code']); } public function test_no_matching_language_completes_with_zero_deliveries_and_all_agents_does_not_include_their_members(): void { $zero = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [3]]); $this->assertSame('sent', $zero['status']); $this->assertSame(0, $zero['recipient_count']); $this->assertSame(1, $zero['skipped_count']); $agents = $this->send(['recipient_type' => 'all_agents', 'translations' => ['vi' => ['title' => 'Vi', 'content' => 'Chỉ đại lý']]]); $this->assertSame(1, $agents['recipient_count']); $receipt = DB::table('site_message_recipients')->where('message_id', $agents['id'])->first(); $this->assertSame('agent', $receipt->recipient_kind); $this->assertSame(3, $receipt->recipient_id); $this->assertSame(0, $this->inbox->unread('user', 3)['count']); } public function test_agent_inbox_with_real_agent_token_middleware_cannot_read_member_messages(): void { Schema::create('agent_tokens', function (Blueprint $t) { $t->id(); $t->unsignedBigInteger('agent_id'); $t->string('token_hash'); $t->dateTime('expires_at'); $t->dateTime('last_used_at')->nullable(); $t->timestamps(); }); DB::table('agent_tokens')->insert(['agent_id' => 1, 'token_hash' => hash('sha256', 'agent-one'), 'expires_at' => now()->addDay()]); $member = $this->send(); $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1]]); $request = Request::create('/agent/messages', 'GET', ['user_id' => 2]); $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request); $response = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->index($this->inbox)); $body = $response->getData(true); $this->assertSame(0, $body['code']); $this->assertSame([$agent['id']], array_column($body['data']['list'], 'id')); $request = Request::create('/agent/messages/detail', 'GET', ['id' => $member['id']]); $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request); $denied = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->detail($this->inbox)); $this->assertSame(-3, $denied->getData(true)['code']); $request = Request::create('/agent/messages'); $this->app->instance('request', $request); $this->assertSame(101011, (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => null)->getData(true)['code']); } }