AgentApplicationTest.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Console\Commands\UnlinkAgentOwners;
  4. use App\Http\Controllers\admin\AgentApplication as AdminApplication;
  5. use App\Models\Agent\Agent;
  6. use App\Models\Agent\AgentApplication;
  7. use App\Services\Agent\AgentApplicationService;
  8. use App\Services\Agent\AgentService;
  9. use App\Services\SiteInboxService;
  10. use Illuminate\Config\Repository;
  11. use Illuminate\Database\Capsule\Manager;
  12. use Illuminate\Database\QueryException;
  13. use Illuminate\Database\Schema\Blueprint;
  14. use Illuminate\Foundation\Application;
  15. use Illuminate\Hashing\BcryptHasher;
  16. use Illuminate\Http\JsonResponse;
  17. use Illuminate\Http\Request;
  18. use Illuminate\Support\Facades\DB;
  19. use Illuminate\Support\Facades\Facade;
  20. use Illuminate\Support\Facades\Hash;
  21. use Illuminate\Support\Facades\Schema;
  22. use Illuminate\Translation\ArrayLoader;
  23. use Illuminate\Translation\Translator;
  24. use Illuminate\Validation\Factory;
  25. use PHPUnit\Framework\TestCase;
  26. use Symfony\Component\Console\Tester\CommandTester;
  27. class AgentApplicationTest extends TestCase
  28. {
  29. private Application $app;
  30. private AgentService $agents;
  31. private AgentApplicationService $service;
  32. protected function setUp(): void
  33. {
  34. Facade::clearResolvedInstances();
  35. $this->app = new Application(dirname(__DIR__, 2));
  36. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  37. Facade::setFacadeApplication($this->app);
  38. $db = new Manager($this->app);
  39. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  40. $db->setAsGlobal(); $db->bootEloquent();
  41. $this->app->instance('db', $db->getDatabaseManager());
  42. $this->app->bind('db.schema', fn () => $db->schema());
  43. $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));
  44. $this->app->instance('log', new \Psr\Log\NullLogger());
  45. $translator = new Translator(new ArrayLoader(), 'zh');
  46. $this->app->instance('translator', $translator);
  47. $validator = new Factory($translator, $this->app);
  48. $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
  49. $this->app->instance('validator', $validator);
  50. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  51. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  52. Schema::create('users', function (Blueprint $t) {
  53. $t->id(); $t->string('user_id')->unique(); $t->string('member_id')->unique(); $t->string('account');
  54. $t->string('username')->default(''); $t->string('first_name')->default(''); $t->integer('from')->default(1);
  55. $t->integer('is_banned')->default(0); $t->dateTime('delete_time')->nullable(); $t->string('language')->nullable(); $t->timestamps();
  56. });
  57. // Metadata only: the existing profit-profile migration inspects MySQL
  58. // indexes; the application and all business queries still use real SQLite.
  59. DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
  60. DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
  61. DB::statement('CREATE TABLE information_schema.statistics (table_schema TEXT,table_name TEXT,index_name TEXT,column_name TEXT,non_unique INTEGER,seq_in_index INTEGER)');
  62. foreach (['2026_08_24_140000_create_agent_system_tables.php', '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
  63. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  64. }
  65. // Pre-upgrade flow commission structures; 2026_11_07 removes them again.
  66. Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
  67. $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
  68. });
  69. Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
  70. $t->id(); $t->unsignedBigInteger('agent_id'); $t->date('effective_from'); $t->timestamps();
  71. });
  72. Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable());
  73. Schema::table('agent_profit_commission_profiles', fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
  74. Schema::create('operation_audits', function (Blueprint $t) {
  75. $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action'); $t->json('changes');
  76. $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
  77. });
  78. foreach (['2026_10_26_110000_create_site_messages.php', '2026_10_27_150000_create_site_realtime_events.php', '2026_10_29_140000_create_agent_applications.php',
  79. '2026_11_07_120000_agent_joint_venture_and_equity.php', '2026_11_08_150000_add_type_to_agent_applications.php',
  80. '2026_11_10_120000_add_is_test_to_agents.php'] as $file) {
  81. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  82. }
  83. foreach ([11 => 'zh', 22 => 'en', 33 => null] as $id => $language) DB::table('users')->insert([
  84. 'id' => $id, 'user_id' => (string) (10000 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
  85. 'first_name' => 'Member ' . $id, 'from' => 1, 'language' => $language, 'agent_id' => 99,
  86. ]);
  87. $this->agents = new AgentService();
  88. $this->service = new AgentApplicationService();
  89. $this->request();
  90. }
  91. protected function tearDown(): void
  92. {
  93. DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
  94. }
  95. private function request(array $params = []): void
  96. {
  97. $request = Request::create('/admin/agent/applicationAudit', 'POST', $params);
  98. $request->user = (object) ['id' => 1, 'username' => 'admin'];
  99. $this->app->instance('request', $request);
  100. }
  101. private function application(int $userId = 11, string $username = 'newagent', int $type = Agent::TYPE_JOINT): AgentApplication
  102. {
  103. return AgentApplication::create([
  104. 'type' => $type, 'user_id' => $userId, 'pending_user_id' => $userId, 'username' => $username, 'pending_username' => $username,
  105. 'password_hash' => password_hash('login-secret', PASSWORD_BCRYPT, ['cost' => 4]),
  106. 'withdraw_password_hash' => password_hash('withdraw-secret', PASSWORD_BCRYPT, ['cost' => 4]), 'status' => 'pending',
  107. ]);
  108. }
  109. private function audit(int $id, string $status = 'approved', array $extra = []): array
  110. {
  111. $this->request(['id' => $id, 'status' => $status] + $extra);
  112. return (new AdminApplication())->audit($this->service, $this->agents)->getData(true);
  113. }
  114. public function test_approval_creates_independent_agent_keeps_passwords_and_sends_one_private_message(): void
  115. {
  116. $application = $this->application();
  117. $response = $this->audit($application->id);
  118. $this->assertSame(0, $response['code'], $response['msg']);
  119. $agent = Agent::firstOrFail();
  120. $this->assertNull($agent->owner_user_id);
  121. $this->assertSame(1, $agent->level);
  122. $this->assertSame(1, $agent->status);
  123. $this->assertTrue(Hash::check('login-secret', $agent->password));
  124. $this->assertTrue(Hash::check('withdraw-secret', $agent->withdraw_password));
  125. $this->assertSame(99, (int) DB::table('users')->where('id', 11)->value('agent_id'));
  126. $this->assertSame(Agent::TYPE_JOINT, $agent->type);
  127. $this->assertNull($agent->profit_commission_profile_id, 'the agent applies for a plan in its own backend');
  128. $application->refresh();
  129. $this->assertSame('approved', $application->status);
  130. $this->assertSame($agent->id, (int) $application->agent_id);
  131. $this->assertNull($application->password_hash); $this->assertNull($application->withdraw_password_hash);
  132. $this->assertNull($application->pending_user_id); $this->assertNull($application->pending_username);
  133. $inbox = new SiteInboxService();
  134. $message = DB::table('site_messages')->first();
  135. $this->assertStringContainsString('newagent', $inbox->detail('user', 11, $message->id)['content']);
  136. $this->assertSame(1, $inbox->unread('user', 11)['count']);
  137. $this->assertSame(0, $inbox->unread('user', 22)['count']);
  138. $this->assertSame(1, DB::table('site_realtime_events')->count());
  139. $this->assertSame(0, $this->audit($application->id)['code']);
  140. $this->assertSame(1, Agent::count()); $this->assertSame(1, DB::table('site_messages')->count());
  141. $this->assertNotSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Cannot reverse'])['code']);
  142. $payload = json_encode($response) . DB::table('operation_audits')->get()->toJson() . DB::table('site_message_translations')->get()->toJson();
  143. foreach (['login-secret', 'withdraw-secret', 'password_hash', 'withdraw_password_hash'] as $secret) $this->assertStringNotContainsString($secret, $payload);
  144. }
  145. public function test_rejection_requires_reason_releases_pending_keys_and_notifies_in_member_language(): void
  146. {
  147. $application = $this->application(22);
  148. $this->assertNotSame(0, $this->audit($application->id, 'rejected')['code']);
  149. $this->assertSame('pending', $application->refresh()->status);
  150. $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
  151. $this->assertSame(0, Agent::count());
  152. $this->assertSame('rejected', $application->refresh()->status);
  153. $this->assertNull($application->pending_username); $this->assertNull($application->pending_user_id);
  154. $receipt = DB::table('site_message_recipients')->first();
  155. $this->assertSame('en', $receipt->language); $this->assertSame(22, (int) $receipt->recipient_id);
  156. $content = (new SiteInboxService())->detail('user', 22, $receipt->message_id)['content'];
  157. $this->assertStringContainsString('Please update your information', $content);
  158. $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
  159. $this->assertSame(1, DB::table('site_messages')->count());
  160. $this->application(22);
  161. $this->assertSame(2, AgentApplication::count());
  162. }
  163. public function test_notification_failure_rolls_back_agent_approval_and_profile_assignments(): void
  164. {
  165. $application = $this->application();
  166. DB::statement("CREATE TRIGGER reject_agent_notice BEFORE INSERT ON bot_site_messages BEGIN SELECT RAISE(ABORT, 'notice failure'); END");
  167. try {
  168. $this->service->audit(['id' => $application->id, 'status' => 'approved'], $this->agents);
  169. $this->fail('Notification failure must roll back approval');
  170. } catch (QueryException $e) { $this->assertStringContainsString('notice failure', $e->getMessage()); }
  171. $this->assertSame(0, Agent::count());
  172. $this->assertSame(0, DB::table('agent_profit_commission_assignments')->count());
  173. $this->assertSame('pending', $application->refresh()->status);
  174. $this->assertNotNull($application->password_hash); $this->assertSame(11, (int) $application->pending_user_id);
  175. $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::transactionLevel());
  176. }
  177. public function test_already_approved_member_cannot_open_a_second_agent_through_another_application(): void
  178. {
  179. $application = $this->application();
  180. $this->assertSame(0, $this->audit($application->id)['code']);
  181. $another = $this->application(11, 'anotheragent');
  182. $this->assertSame('该会员已有通过的代理申请', $this->audit($another->id)['msg']);
  183. $this->assertSame(1, Agent::count());
  184. $this->assertSame('pending', $another->refresh()->status);
  185. }
  186. public function test_unlink_command_previews_and_only_updates_explicitly_selected_agent_owners(): void
  187. {
  188. $first = $this->agents->create(['username' => 'aa123', 'password' => 'legacy-secret']);
  189. $second = $this->agents->create(['username' => 'anotheragent', 'password' => 'legacy-secret']);
  190. DB::table('agents')->where('id', $first->id)->update(['owner_user_id' => 11]);
  191. DB::table('agents')->where('id', $second->id)->update(['owner_user_id' => 22]);
  192. $before = DB::table('agents')->where('id', $first->id)->first();
  193. $users = DB::table('users')->orderBy('id')->get()->toJson();
  194. $command = new UnlinkAgentOwners();
  195. $command->setLaravel($this->app);
  196. $tester = new CommandTester($command);
  197. $this->assertSame(0, $tester->execute([]));
  198. $preview = $tester->getDisplay();
  199. foreach (['检查到关联:2 条', '实际更新:0 条', 'bot_agents', 'owner_user_id', 'aa123', 'anotheragent', 'NULL'] as $value) {
  200. $this->assertStringContainsString($value, $preview);
  201. }
  202. $this->assertEquals($before, DB::table('agents')->where('id', $first->id)->first());
  203. $this->assertSame(2, $tester->execute(['--write' => true]));
  204. $this->assertSame(0, $tester->execute(['--username' => 'aa123', '--write' => true]));
  205. $this->assertStringContainsString('实际更新:1 条', $tester->getDisplay());
  206. $this->assertStringNotContainsString('anotheragent', $tester->getDisplay());
  207. $before->owner_user_id = null;
  208. $this->assertEquals($before, DB::table('agents')->where('id', $first->id)->first());
  209. $this->assertSame(22, (int) DB::table('agents')->where('id', $second->id)->value('owner_user_id'));
  210. $this->assertSame(0, $tester->execute(['--all' => true, '--write' => true]));
  211. $this->assertStringContainsString('实际更新:1 条', $tester->getDisplay());
  212. $this->assertSame(0, DB::table('agents')->whereNotNull('owner_user_id')->count());
  213. $this->assertSame($users, DB::table('users')->orderBy('id')->get()->toJson());
  214. $this->assertSame(0, $tester->execute(['--all' => true, '--write' => true]));
  215. $this->assertStringContainsString('实际更新:0 条', $tester->getDisplay());
  216. }
  217. public function test_listing_hides_credentials_and_unknown_member_language_gets_a_private_fallback_notice(): void
  218. {
  219. $application = $this->application(33);
  220. $listing = $this->service->listing(['status' => 'pending', 'member_id' => 'M33']);
  221. $this->assertSame(1, $listing['total']);
  222. $this->assertSame('member33', $listing['data'][0]->member_account);
  223. $this->assertStringNotContainsString('password', json_encode($listing));
  224. $this->assertSame(0, $this->audit($application->id)['code']);
  225. $this->assertSame('zh', DB::table('site_message_recipients')->value('language'));
  226. $this->assertSame(1, (new SiteInboxService())->unread('user', 33)['count']);
  227. }
  228. public function test_admin_update_no_longer_binds_member_identity(): void
  229. {
  230. $agent = $this->agents->create(['username' => 'oldaccount', 'password' => 'legacy-secret', 'real_name' => 'Original']);
  231. $this->request(['id' => $agent->id, 'owner_user_id' => 11, 'real_name' => 'Renamed']);
  232. $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
  233. $this->assertSame(0, $response['code'], $response['msg']);
  234. $this->assertArrayNotHasKey('owner_user_id', $response['data']);
  235. $this->assertArrayHasKey('profit_commission_profile', $response['data']);
  236. $this->assertSame('Renamed', $agent->refresh()->real_name);
  237. $this->assertNull($agent->owner_user_id);
  238. }
  239. public function test_application_routes_keep_the_existing_admin_auth_and_agent_management_restriction(): void
  240. {
  241. $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
  242. $this->app->instance('router', $router);
  243. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  244. foreach ([['/admin/agent/applications', 'GET'], ['/admin/agent/applicationAudit', 'POST']] as [$path, $method]) {
  245. $route = $router->getRoutes()->match(Request::create($path, $method));
  246. $this->assertContains('admin.jwt', $route->gatherMiddleware());
  247. $this->assertContains('agent.admin', $route->gatherMiddleware());
  248. }
  249. $this->request(); request()->user = (object) ['id' => 20, 'username' => 'ordinary-admin'];
  250. $called = false;
  251. $response = (new \App\Http\Middleware\AgentAdminMiddleware())->handle(request(), function () use (&$called) { $called = true; });
  252. $this->assertFalse($called); $this->assertNotSame(0, $response->getData(true)['code']);
  253. }
  254. public function test_newly_unavailable_account_or_username_cannot_be_approved(): void
  255. {
  256. $application = $this->application();
  257. DB::table('users')->where('id', 11)->update(['is_banned' => 1]);
  258. $this->assertNotSame(0, $this->audit($application->id)['code']);
  259. $this->assertSame('pending', $application->refresh()->status);
  260. $this->assertSame(0, Agent::count());
  261. $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => '账号已停用'])['code']);
  262. $this->assertSame(1, DB::table('site_messages')->count());
  263. $other = $this->application(22, 'takenname');
  264. $this->agents->create(['username' => 'TakenName', 'password' => 'existing-secret']);
  265. $response = $this->audit($other->id);
  266. $this->assertNotSame(0, $response['code']);
  267. $this->assertStringContainsString('账号已被使用', $response['msg']);
  268. $this->assertSame('pending', $other->refresh()->status);
  269. $this->assertSame(1, Agent::count());
  270. $this->assertSame(1, DB::table('site_messages')->count());
  271. }
  272. public function test_approval_always_uses_the_member_application_type(): void
  273. {
  274. $equity = $this->application(11, 'equityagent', Agent::TYPE_EQUITY);
  275. $listed = $this->service->listing(['type' => Agent::TYPE_EQUITY])['data'];
  276. $this->assertCount(1, $listed);
  277. $this->assertSame(Agent::TYPE_EQUITY, $listed[0]->type);
  278. $this->assertSame('入股', $listed[0]->type_text);
  279. $this->assertSame(0, $this->service->listing(['type' => Agent::TYPE_JOINT])['total']);
  280. $this->assertSame(0, $this->audit($equity->id)['code']);
  281. $this->assertSame(Agent::TYPE_EQUITY, (int) Agent::where('username', 'equityagent')->value('type'));
  282. $joint = $this->application(22, 'jointagent', Agent::TYPE_JOINT);
  283. $response = $this->audit($joint->id, 'approved', ['type' => Agent::TYPE_EQUITY]);
  284. $this->assertSame(0, $response['code'], $response['msg']);
  285. $this->assertSame(Agent::TYPE_JOINT, (int) Agent::where('username', 'jointagent')->value('type'));
  286. $this->assertSame(Agent::TYPE_JOINT, $joint->fresh()->type);
  287. $this->assertSame('合营', $this->service->listing(['id' => $joint->id])['data'][0]->type_text);
  288. }
  289. }