SiteMessageTest.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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();
  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_specified_users_and_agents_use_distinct_id_namespaces_and_retraction_hides_every_read_path(): void
  93. {
  94. $user = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [1, 2, 1], 'request_id' => 'select']);
  95. $again = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [2, 1], 'request_id' => 'select']);
  96. $this->assertSame($user['id'], $again['id']);
  97. $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1, 3], 'translations' => ['zh' => ['title' => '代理', 'content' => '仅代理本人']]]);
  98. $this->assertSame(1, $agent['recipient_count']);
  99. $this->assertSame('仅代理本人', $this->inbox->detail('agent', 1, $agent['id'])['content']);
  100. try { $this->inbox->detail('user', 1, $agent['id']); $this->fail('Cross-kind access'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  101. $this->assertSame(1, $this->inbox->unread('agent', 1)['count']);
  102. $this->service->retract($agent['id']); $logs = DB::table('operation_audits')->count();
  103. $this->service->retract($agent['id']); $this->assertSame($logs, DB::table('operation_audits')->count());
  104. $this->assertSame(0, $this->inbox->listing('agent', 1, [])['count']); $this->assertSame(0, $this->inbox->unread('agent', 1)['count']);
  105. foreach ([fn () => $this->inbox->detail('agent', 1, $agent['id']), fn () => $this->inbox->read('agent', 1, $agent['id'])] as $call) {
  106. try { $call(); $this->fail('Retracted read'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  107. }
  108. $this->assertSame('retracted', $this->service->detail($agent['id'])['status']);
  109. $this->assertSame(2, $this->service->listing([])['total']);
  110. }
  111. public function test_read_is_explicit_owned_and_preserves_first_read_time_and_delivery_language(): void
  112. {
  113. $message = $this->send(); $id = $message['id'];
  114. $this->inbox->detail('user', 1, $id); $this->assertSame(1, $this->inbox->unread('user', 1)['count']);
  115. $first = $this->inbox->read('user', 1, $id);
  116. Carbon::setTestNow(now()->addHour());
  117. $this->assertSame($first, $this->inbox->read('user', 1, $id));
  118. $this->assertSame(0, $this->inbox->listing('user', 1, ['is_read' => 0])['count']);
  119. $this->assertSame(1, $this->inbox->listing('user', 1, ['is_read' => 1])['count']);
  120. DB::table('users')->where('id', 1)->update(['language' => 'vi']);
  121. $this->assertSame('zh', $this->inbox->detail('user', 1, $id)['language']);
  122. $this->assertSame('中文正文', $this->inbox->detail('user', 1, $id)['content']);
  123. $this->expectException(ValidationException::class); $this->inbox->read('user', 3, $id);
  124. }
  125. public function test_scheduled_delivery_snapshots_accounts_and_language_at_actual_send_and_can_be_cancelled(): void
  126. {
  127. $message = $this->send(['send_at' => '2026-10-26 11:00:00']);
  128. $cancel = $this->send(['send_at' => '2026-10-26 11:00:00']); $this->service->retract($cancel['id']);
  129. $this->assertSame('scheduled', $message['status']); $this->assertSame(0, DB::table('site_message_recipients')->count());
  130. DB::table('users')->where('id', 2)->update(['language' => 'vi']);
  131. DB::table('users')->insert(['id' => 9, 'user_id' => '109', 'member_id' => 'M9', 'account' => 'late', 'username' => '', 'first_name' => 'later', 'language' => 'zh', 'from' => 1]);
  132. $this->assertSame(0, $this->service->dispatchDue()['sent']);
  133. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  134. $this->assertSame(1, $this->service->dispatchDue()['sent']);
  135. $this->assertSame([1, 9], DB::table('site_message_recipients')->orderBy('recipient_id')->pluck('recipient_id')->all());
  136. DB::table('users')->insert(['id' => 10, 'user_id' => '110', 'member_id' => 'M10', 'account' => 'after', 'username' => '', 'first_name' => 'after', 'language' => 'zh', 'from' => 1]);
  137. $this->assertSame(0, $this->service->dispatchDue()['sent']); $this->assertSame(0, $this->inbox->unread('user', 10)['count']);
  138. $this->assertSame('系统', DB::table('operation_audits')->where('resource_id', $message['id'])->where('action', 'send')->value('operator_name'));
  139. }
  140. public function test_lightweight_search_language_filters_and_agent_permissions_do_not_leak_finance_or_other_kinds(): void
  141. {
  142. $users = $this->service->recipients('user', ['q' => 'member', 'limit' => 2]);
  143. $this->assertSame(4, $users['total']); $this->assertCount(2, $users['data']);
  144. $this->assertSame(['id', 'user_id', 'member_id', 'account', 'username', 'first_name', 'language'], array_keys((array) $users['data'][0]));
  145. $this->assertSame(2, $this->service->recipients('user', ['q' => '102'])['data'][0]->id);
  146. $this->send(); $this->send(['recipient_type' => 'all_agents']);
  147. $this->assertSame(2, $this->service->listing(['language' => 'en'])['total']);
  148. $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']);
  149. $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']);
  150. $this->assertSame(2, $this->service->logs(['language' => 'en'])['total']);
  151. $this->request([], 2);
  152. $this->assertSame(1, $this->service->listing([])['total']); $this->assertSame(1, $this->service->logs([])['total']);
  153. foreach ([fn () => $this->service->recipients('agent', []), fn () => $this->send(['recipient_type' => 'all_agents'])] as $call) {
  154. try { $call(); $this->fail('Agent management bypass'); } catch (AuthorizationException $e) { $this->assertNotEmpty($e->getMessage()); }
  155. }
  156. }
  157. public function test_delivery_and_audit_failures_roll_back_everything_and_retry_once(): void
  158. {
  159. DB::statement("CREATE TRIGGER reject_receipts BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'delivery unavailable'); END");
  160. try { $this->send(); $this->fail('Expected failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('delivery unavailable', $e->getMessage()); }
  161. $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('site_message_translations')->count());
  162. DB::statement('DROP TRIGGER reject_receipts');
  163. $message = $this->send(['send_at' => '2026-10-26 11:00:00']);
  164. DB::statement("CREATE TRIGGER reject_message_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END");
  165. Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
  166. $this->assertSame(1, $this->service->dispatchDue()['failed']);
  167. $this->assertSame('scheduled', $this->service->detail($message['id'])['status']); $this->assertSame(0, DB::table('site_message_recipients')->count());
  168. DB::statement('DROP TRIGGER reject_message_audit');
  169. $this->assertSame(1, $this->service->dispatchDue()['sent']); $this->assertSame(2, DB::table('site_message_recipients')->count());
  170. }
  171. /** @dataProvider invalidInputs */
  172. public function test_invalid_messages_never_leave_partial_rows(array $input): void
  173. {
  174. try { $this->send($input); $this->fail('Expected validation failure'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
  175. $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('operation_audits')->count());
  176. }
  177. public static function invalidInputs(): array
  178. {
  179. return array_map(fn ($x) => [$x], [['translations' => []], ['translations' => ['zh' => ['content' => '']]],
  180. ['translations' => ['de' => ['title' => 'x', 'content' => 'x']]], ['translations' => ['zh' => ['title' => '', 'content' => 'x']]],
  181. ['translations' => ['en' => ['title' => str_repeat('a', 501), 'content' => 'x']]], ['translations' => ['vi' => ['title' => 'x', 'content' => str_repeat('a', 5001)]]],
  182. ['recipient_type' => 'specified_users', 'recipient_ids' => []], ['recipient_type' => 'specified_users', 'recipient_ids' => [5]],
  183. ['recipient_type' => 'specified_users', 'recipient_ids' => [8]], ['recipient_type' => 'all_users', 'recipient_ids' => [1]],
  184. ['send_at' => '2026-02-30 10:00:00'], ['sender_id' => 99], ['request_id' => ''], ['category' => 'other']]);
  185. }
  186. public function test_agent_language_only_profile_update_keeps_real_name_and_all_new_routes_use_correct_auth(): void
  187. {
  188. DB::table('agents')->insert(['id' => 4, 'username' => 'new', 'real_name' => '保留姓名']);
  189. $this->assertSame('zh', DB::table('agents')->where('id', 4)->value('language'));
  190. $request = $this->request(['language' => 'en']); $request->attributes->set('agent', Agent::findOrFail(4));
  191. $this->assertSame(0, (new ProfileController())->update($request)->getData(true)['code']);
  192. $this->assertSame('保留姓名', DB::table('agents')->where('id', 4)->value('real_name'));
  193. $this->assertSame('en', DB::table('agents')->where('id', 4)->value('language'));
  194. $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
  195. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  196. $router->group(['prefix' => 'agent'], function () { require dirname(__DIR__, 2) . '/routes/agent.php'; });
  197. foreach (['/admin/message', '/admin/message/recipients/users', '/admin/message/recipients/agents', '/admin/message/logs'] as $uri) {
  198. $route = $router->getRoutes()->match(Request::create($uri)); $this->assertContains('admin.jwt', $route->middleware());
  199. }
  200. $this->assertContains('agent.auth', $router->getRoutes()->match(Request::create('/agent/messages'))->middleware());
  201. $this->assertContains('agent.admin', $router->getRoutes()->match(Request::create('/admin/message/recipients/agents'))->middleware());
  202. $this->request(['translations' => []]); $this->assertSame(-3, (new SiteMessage())->send($this->service)->getData(true)['code']);
  203. }
  204. public function test_no_matching_language_completes_with_zero_deliveries_and_all_agents_does_not_include_their_members(): void
  205. {
  206. $zero = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [3]]);
  207. $this->assertSame('sent', $zero['status']); $this->assertSame(0, $zero['recipient_count']); $this->assertSame(1, $zero['skipped_count']);
  208. $agents = $this->send(['recipient_type' => 'all_agents', 'translations' => ['vi' => ['title' => 'Vi', 'content' => 'Chỉ đại lý']]]);
  209. $this->assertSame(1, $agents['recipient_count']);
  210. $receipt = DB::table('site_message_recipients')->where('message_id', $agents['id'])->first();
  211. $this->assertSame('agent', $receipt->recipient_kind); $this->assertSame(3, $receipt->recipient_id);
  212. $this->assertSame(0, $this->inbox->unread('user', 3)['count']);
  213. }
  214. public function test_agent_inbox_with_real_agent_token_middleware_cannot_read_member_messages(): void
  215. {
  216. Schema::create('agent_tokens', function (Blueprint $t) {
  217. $t->id(); $t->unsignedBigInteger('agent_id'); $t->string('token_hash'); $t->dateTime('expires_at'); $t->dateTime('last_used_at')->nullable(); $t->timestamps();
  218. });
  219. DB::table('agent_tokens')->insert(['agent_id' => 1, 'token_hash' => hash('sha256', 'agent-one'), 'expires_at' => now()->addDay()]);
  220. $member = $this->send();
  221. $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1]]);
  222. $request = Request::create('/agent/messages', 'GET', ['user_id' => 2]);
  223. $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request);
  224. $response = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->index($this->inbox));
  225. $body = $response->getData(true); $this->assertSame(0, $body['code']);
  226. $this->assertSame([$agent['id']], array_column($body['data']['list'], 'id'));
  227. $request = Request::create('/agent/messages/detail', 'GET', ['id' => $member['id']]);
  228. $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request);
  229. $denied = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->detail($this->inbox));
  230. $this->assertSame(-3, $denied->getData(true)['code']);
  231. $request = Request::create('/agent/messages'); $this->app->instance('request', $request);
  232. $this->assertSame(101011, (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => null)->getData(true)['code']);
  233. }
  234. }