| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- <?php
- namespace Tests\Integration;
- use App\Http\Controllers\admin\SiteMessage;
- use App\Http\Controllers\agent\ProfileController;
- use App\Models\Agent\Agent;
- use App\Services\SiteInboxService;
- use App\Services\SiteMessageService;
- use Carbon\Carbon;
- use Illuminate\Auth\Access\AuthorizationException;
- 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;
- /** Real dispatch SQL, translations, ownership, retraction and audit transactions; SQLite only. */
- class SiteMessageTest extends TestCase
- {
- private Application $app;
- private SiteMessageService $service;
- private SiteInboxService $inbox;
- private int $sequence = 0;
- protected function setUp(): void
- {
- parent::setUp(); Facade::clearResolvedInstances();
- Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
- $this->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']);
- }
- }
|