SiteMessageTest.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\SiteMessage;
  4. use App\Http\Controllers\agent\ProfileController;
  5. use App\Models\Agent\Agent;
  6. use App\Services\SiteInboxService;
  7. use App\Services\SiteMessageService;
  8. use Carbon\Carbon;
  9. use Illuminate\Auth\Access\AuthorizationException;
  10. use Illuminate\Config\Repository;
  11. use Illuminate\Database\Capsule\Manager;
  12. use Illuminate\Database\Schema\Blueprint;
  13. use Illuminate\Events\Dispatcher;
  14. use Illuminate\Foundation\Application;
  15. use Illuminate\Http\JsonResponse;
  16. use Illuminate\Http\Request;
  17. use Illuminate\Routing\Router;
  18. use Illuminate\Support\Facades\DB;
  19. use Illuminate\Support\Facades\Facade;
  20. use Illuminate\Support\Facades\Schema;
  21. use Illuminate\Translation\ArrayLoader;
  22. use Illuminate\Translation\Translator;
  23. use Illuminate\Validation\Factory;
  24. use Illuminate\Validation\ValidationException;
  25. use PHPUnit\Framework\TestCase;
  26. /** Real dispatch SQL, translations, ownership, retraction and audit transactions; SQLite only. */
  27. class SiteMessageTest extends TestCase
  28. {
  29. private Application $app;
  30. private SiteMessageService $service;
  31. private SiteInboxService $inbox;
  32. private int $sequence = 0;
  33. protected function setUp(): void
  34. {
  35. parent::setUp(); Facade::clearResolvedInstances();
  36. Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
  37. $this->app = new Application(dirname(__DIR__, 2));
  38. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'agent' => ['admin_ids' => [1]]]));
  39. Facade::setFacadeApplication($this->app);
  40. $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  41. $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager());
  42. $this->app->bind('db.schema', fn () => $db->schema());
  43. $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
  44. $this->app->instance('validator', new Factory($translator, $this->app)); $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('users', function (Blueprint $t) {
  50. $t->id(); $t->string('user_id'); $t->string('member_id'); $t->string('account'); $t->string('username'); $t->string('first_name');
  51. $t->string('language')->nullable(); $t->integer('from'); $t->dateTime('delete_time')->nullable(); $t->timestamps();
  52. });
  53. Schema::create('agents', function (Blueprint $t) { $t->id(); $t->string('username'); $t->string('real_name'); $t->integer('status')->default(1); $t->timestamps(); });
  54. Schema::create('operation_audits', function (Blueprint $t) {
  55. $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
  56. $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
  57. });
  58. $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
  59. 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]) {
  60. DB::table('users')->insert(['id' => $id, 'user_id' => (string) (100 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
  61. 'username' => 'name' . $id, 'first_name' => '昵称' . $id, 'language' => $lang, 'from' => $from, 'delete_time' => $id === 8 ? now() : null]);
  62. }
  63. 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]);
  64. $this->service = new SiteMessageService(); $this->inbox = new SiteInboxService(); $this->request();
  65. }
  66. protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
  67. private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_110000_create_site_messages.php'; }
  68. private function request(array $params = [], int $admin = 1): Request
  69. {
  70. $request = Request::create('/admin/message/send', 'POST', $params); $request->user = (object) ['id' => $admin, 'username' => 'admin' . $admin];
  71. $this->app->instance('request', $request); return $request;
  72. }
  73. private function send(array $extra = []): array
  74. {
  75. return $this->service->send($extra + ['request_id' => 'fixture-' . ++$this->sequence, 'recipient_type' => 'all_users',
  76. 'translations' => ['zh' => ['title' => '中文标题', 'subtitle' => '副标题', 'content' => '中文正文'], 'en' => ['title' => 'English', 'content' => 'English body'], 'vi' => ['content' => '']]]);
  77. }
  78. public function test_language_specific_delivery_is_real_frontend_only_and_retries_are_idempotent(): void
  79. {
  80. $a = $this->send(['request_id' => 'same']); $b = $this->send(['request_id' => 'same']);
  81. $this->assertSame($a['id'], $b['id']); $this->assertSame('sent', $a['status']);
  82. $this->assertSame(2, $a['recipient_count']); $this->assertSame(2, $a['skipped_count']);
  83. $this->assertSame(2, DB::table('site_message_translations')->count());
  84. $this->assertSame([1, 2], DB::table('site_message_recipients')->orderBy('recipient_id')->pluck('recipient_id')->all());
  85. $this->assertSame('中文正文', $this->inbox->detail('user', 1, $a['id'])['content']);
  86. $this->assertSame('English body', $this->inbox->detail('user', 2, $a['id'])['content']);
  87. $this->assertSame(0, $this->inbox->unread('user', 3)['count']);
  88. $this->assertSame(0, $this->inbox->unread('agent', 1)['count']);
  89. $this->assertSame(1, DB::table('operation_audits')->count());
  90. $this->expectException(ValidationException::class); $this->send(['request_id' => 'same', 'category' => 'promotion']);
  91. }
  92. public function test_websocket_events_are_committed_with_delivery_and_retraction_and_retries_do_not_duplicate(): void
  93. {
  94. $message = $this->send(['request_id' => 'push-once']); $this->send(['request_id' => 'push-once']);
  95. $this->assertSame(1, DB::table('site_realtime_events')->count());
  96. $this->assertSame('message', DB::table('site_realtime_events')->first()->module);
  97. $this->assertSame($message['id'], (int) DB::table('site_realtime_events')->first()->resource_id);
  98. $this->service->retract($message['id']); $this->service->retract($message['id']);
  99. $this->assertSame(['send', 'retract'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
  100. $this->send(['send_at' => '2026-10-26 11:00:00']);
  101. $this->assertSame(2, DB::table('site_realtime_events')->count());
  102. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->service->dispatchDue();
  103. $this->assertSame(3, DB::table('site_realtime_events')->count());
  104. DB::beginTransaction(); $this->send(); DB::rollBack();
  105. $this->assertSame(3, DB::table('site_realtime_events')->count());
  106. }
  107. public function test_specified_users_and_agents_use_distinct_id_namespaces_and_retraction_hides_every_read_path(): void
  108. {
  109. $user = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [1, 2, 1], 'request_id' => 'select']);
  110. $again = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [2, 1], 'request_id' => 'select']);
  111. $this->assertSame($user['id'], $again['id']);
  112. $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1, 3], 'translations' => ['zh' => ['title' => '代理', 'content' => '仅代理本人']]]);
  113. $this->assertSame(1, $agent['recipient_count']);
  114. $this->assertSame('仅代理本人', $this->inbox->detail('agent', 1, $agent['id'])['content']);
  115. try { $this->inbox->detail('user', 1, $agent['id']); $this->fail('Cross-kind access'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  116. $this->assertSame(1, $this->inbox->unread('agent', 1)['count']);
  117. $this->service->retract($agent['id']); $logs = DB::table('operation_audits')->count();
  118. $this->service->retract($agent['id']); $this->assertSame($logs, DB::table('operation_audits')->count());
  119. $this->assertSame(0, $this->inbox->listing('agent', 1, [])['count']); $this->assertSame(0, $this->inbox->unread('agent', 1)['count']);
  120. foreach ([fn () => $this->inbox->detail('agent', 1, $agent['id']), fn () => $this->inbox->read('agent', 1, $agent['id'])] as $call) {
  121. try { $call(); $this->fail('Retracted read'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  122. }
  123. $this->assertSame('retracted', $this->service->detail($agent['id'])['status']);
  124. $this->assertSame(2, $this->service->listing([])['total']);
  125. }
  126. public function test_read_is_explicit_owned_and_preserves_first_read_time_and_delivery_language(): void
  127. {
  128. $message = $this->send(); $id = $message['id'];
  129. $this->inbox->detail('user', 1, $id); $this->assertSame(1, $this->inbox->unread('user', 1)['count']);
  130. $first = $this->inbox->read('user', 1, $id);
  131. Carbon::setTestNow(now()->addHour());
  132. $this->assertSame($first, $this->inbox->read('user', 1, $id));
  133. $this->assertSame(0, $this->inbox->listing('user', 1, ['is_read' => 0])['count']);
  134. $this->assertSame(1, $this->inbox->listing('user', 1, ['is_read' => 1])['count']);
  135. DB::table('users')->where('id', 1)->update(['language' => 'vi']);
  136. $this->assertSame('zh', $this->inbox->detail('user', 1, $id)['language']);
  137. $this->assertSame('中文正文', $this->inbox->detail('user', 1, $id)['content']);
  138. $this->expectException(ValidationException::class); $this->inbox->read('user', 3, $id);
  139. }
  140. public function test_scheduled_delivery_snapshots_accounts_and_language_at_actual_send_and_can_be_cancelled(): void
  141. {
  142. $message = $this->send(['send_at' => '2026-10-26 11:00:00']);
  143. $cancel = $this->send(['send_at' => '2026-10-26 11:00:00']); $this->service->retract($cancel['id']);
  144. $this->assertSame('scheduled', $message['status']); $this->assertSame(0, DB::table('site_message_recipients')->count());
  145. DB::table('users')->where('id', 2)->update(['language' => 'vi']);
  146. DB::table('users')->insert(['id' => 9, 'user_id' => '109', 'member_id' => 'M9', 'account' => 'late', 'username' => '', 'first_name' => 'later', 'language' => 'zh', 'from' => 1]);
  147. $this->assertSame(0, $this->service->dispatchDue()['sent']);
  148. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  149. $this->assertSame(1, $this->service->dispatchDue()['sent']);
  150. $this->assertSame([1, 9], DB::table('site_message_recipients')->orderBy('recipient_id')->pluck('recipient_id')->all());
  151. DB::table('users')->insert(['id' => 10, 'user_id' => '110', 'member_id' => 'M10', 'account' => 'after', 'username' => '', 'first_name' => 'after', 'language' => 'zh', 'from' => 1]);
  152. $this->assertSame(0, $this->service->dispatchDue()['sent']); $this->assertSame(0, $this->inbox->unread('user', 10)['count']);
  153. $this->assertSame('系统', DB::table('operation_audits')->where('resource_id', $message['id'])->where('action', 'send')->value('operator_name'));
  154. }
  155. public function test_lightweight_search_language_filters_and_agent_permissions_do_not_leak_finance_or_other_kinds(): void
  156. {
  157. $users = $this->service->recipients('user', ['q' => 'member', 'limit' => 2]);
  158. $this->assertSame(4, $users['total']); $this->assertCount(2, $users['data']);
  159. $this->assertSame(['id', 'user_id', 'member_id', 'account', 'username', 'first_name', 'language'], array_keys((array) $users['data'][0]));
  160. $this->assertSame(2, $this->service->recipients('user', ['q' => '102'])['data'][0]->id);
  161. $this->send(); $this->send(['recipient_type' => 'all_agents']);
  162. $this->assertSame(2, $this->service->listing(['language' => 'en'])['total']);
  163. $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']);
  164. $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']);
  165. $this->assertSame(2, $this->service->logs(['language' => 'en'])['total']);
  166. $this->request([], 2);
  167. $this->assertSame(1, $this->service->listing([])['total']); $this->assertSame(1, $this->service->logs([])['total']);
  168. foreach ([fn () => $this->service->recipients('agent', []), fn () => $this->send(['recipient_type' => 'all_agents'])] as $call) {
  169. try { $call(); $this->fail('Agent management bypass'); } catch (AuthorizationException $e) { $this->assertNotEmpty($e->getMessage()); }
  170. }
  171. }
  172. public function test_delivery_and_audit_failures_roll_back_everything_and_retry_once(): void
  173. {
  174. DB::statement("CREATE TRIGGER reject_receipts BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'delivery unavailable'); END");
  175. try { $this->send(); $this->fail('Expected failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('delivery unavailable', $e->getMessage()); }
  176. $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('site_message_translations')->count());
  177. DB::statement('DROP TRIGGER reject_receipts');
  178. $message = $this->send(['send_at' => '2026-10-26 11:00:00']);
  179. DB::statement("CREATE TRIGGER reject_message_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END");
  180. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  181. $this->assertSame(1, $this->service->dispatchDue()['failed']);
  182. $this->assertSame('scheduled', $this->service->detail($message['id'])['status']); $this->assertSame(0, DB::table('site_message_recipients')->count());
  183. DB::statement('DROP TRIGGER reject_message_audit');
  184. $this->assertSame(1, $this->service->dispatchDue()['sent']); $this->assertSame(2, DB::table('site_message_recipients')->count());
  185. }
  186. /** @dataProvider invalidInputs */
  187. public function test_invalid_messages_never_leave_partial_rows(array $input): void
  188. {
  189. try { $this->send($input); $this->fail('Expected validation failure'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  190. $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('operation_audits')->count());
  191. }
  192. public static function invalidInputs(): array
  193. {
  194. return array_map(fn ($x) => [$x], [['translations' => []], ['translations' => ['zh' => ['content' => '']]],
  195. ['translations' => ['de' => ['title' => 'x', 'content' => 'x']]], ['translations' => ['zh' => ['title' => '', 'content' => 'x']]],
  196. ['translations' => ['en' => ['title' => str_repeat('a', 501), 'content' => 'x']]], ['translations' => ['vi' => ['title' => 'x', 'content' => str_repeat('a', 5001)]]],
  197. ['recipient_type' => 'specified_users', 'recipient_ids' => []], ['recipient_type' => 'specified_users', 'recipient_ids' => [5]],
  198. ['recipient_type' => 'specified_users', 'recipient_ids' => [8]], ['recipient_type' => 'all_users', 'recipient_ids' => [1]],
  199. ['send_at' => '2026-02-30 10:00:00'], ['sender_id' => 99], ['request_id' => ''], ['category' => 'other']]);
  200. }
  201. public function test_agent_language_only_profile_update_keeps_real_name_and_all_new_routes_use_correct_auth(): void
  202. {
  203. DB::table('agents')->insert(['id' => 4, 'username' => 'new', 'real_name' => '保留姓名']);
  204. $this->assertSame('zh', DB::table('agents')->where('id', 4)->value('language'));
  205. $request = $this->request(['language' => 'en']); $request->attributes->set('agent', Agent::findOrFail(4));
  206. $this->assertSame(0, (new ProfileController())->update($request)->getData(true)['code']);
  207. $this->assertSame('保留姓名', DB::table('agents')->where('id', 4)->value('real_name'));
  208. $this->assertSame('en', DB::table('agents')->where('id', 4)->value('language'));
  209. $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  210. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  211. $router->group(['prefix' => 'agent'], function () { require dirname(__DIR__, 2) . '/routes/agent.php'; });
  212. foreach (['/admin/message', '/admin/message/recipients/users', '/admin/message/recipients/agents', '/admin/message/logs'] as $uri) {
  213. $route = $router->getRoutes()->match(Request::create($uri)); $this->assertContains('admin.jwt', $route->middleware());
  214. }
  215. $this->assertContains('agent.auth', $router->getRoutes()->match(Request::create('/agent/messages'))->middleware());
  216. $this->assertContains('agent.admin', $router->getRoutes()->match(Request::create('/admin/message/recipients/agents'))->middleware());
  217. $this->request(['translations' => []]); $this->assertSame(-3, (new SiteMessage())->send($this->service)->getData(true)['code']);
  218. }
  219. public function test_no_matching_language_completes_with_zero_deliveries_and_all_agents_does_not_include_their_members(): void
  220. {
  221. $zero = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [3]]);
  222. $this->assertSame('sent', $zero['status']); $this->assertSame(0, $zero['recipient_count']); $this->assertSame(1, $zero['skipped_count']);
  223. $agents = $this->send(['recipient_type' => 'all_agents', 'translations' => ['vi' => ['title' => 'Vi', 'content' => 'Chỉ đại lý']]]);
  224. $this->assertSame(1, $agents['recipient_count']);
  225. $receipt = DB::table('site_message_recipients')->where('message_id', $agents['id'])->first();
  226. $this->assertSame('agent', $receipt->recipient_kind); $this->assertSame(3, $receipt->recipient_id);
  227. $this->assertSame(0, $this->inbox->unread('user', 3)['count']);
  228. }
  229. public function test_agent_inbox_with_real_agent_token_middleware_cannot_read_member_messages(): void
  230. {
  231. Schema::create('agent_tokens', function (Blueprint $t) {
  232. $t->id(); $t->unsignedBigInteger('agent_id'); $t->string('token_hash'); $t->dateTime('expires_at'); $t->dateTime('last_used_at')->nullable(); $t->timestamps();
  233. });
  234. DB::table('agent_tokens')->insert(['agent_id' => 1, 'token_hash' => hash('sha256', 'agent-one'), 'expires_at' => now()->addDay()]);
  235. $member = $this->send();
  236. $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1]]);
  237. $request = Request::create('/agent/messages', 'GET', ['user_id' => 2]);
  238. $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request);
  239. $response = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->index($this->inbox));
  240. $body = $response->getData(true); $this->assertSame(0, $body['code']);
  241. $this->assertSame([$agent['id']], array_column($body['data']['list'], 'id'));
  242. $request = Request::create('/agent/messages/detail', 'GET', ['id' => $member['id']]);
  243. $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request);
  244. $denied = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->detail($this->inbox));
  245. $this->assertSame(-3, $denied->getData(true)['code']);
  246. $request = Request::create('/agent/messages'); $this->app->instance('request', $request);
  247. $this->assertSame(101011, (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => null)->getData(true)['code']);
  248. }
  249. }