SiteMessageTest.php 20 KB

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