AgentApplicationTest.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\AgentApplication as AdminApplication;
  4. use App\Models\Agent\Agent;
  5. use App\Models\Agent\AgentApplication;
  6. use App\Models\Agent\AgentFlowCommissionProfile;
  7. use App\Services\Agent\AgentApplicationService;
  8. use App\Services\Agent\AgentFlowCommissionAssignmentService;
  9. use App\Services\Agent\AgentProfitCommissionAssignmentService;
  10. use App\Services\Agent\AgentService;
  11. use App\Services\SiteInboxService;
  12. use Illuminate\Config\Repository;
  13. use Illuminate\Database\Capsule\Manager;
  14. use Illuminate\Database\QueryException;
  15. use Illuminate\Database\Schema\Blueprint;
  16. use Illuminate\Foundation\Application;
  17. use Illuminate\Hashing\BcryptHasher;
  18. use Illuminate\Http\JsonResponse;
  19. use Illuminate\Http\Request;
  20. use Illuminate\Support\Facades\DB;
  21. use Illuminate\Support\Facades\Facade;
  22. use Illuminate\Support\Facades\Hash;
  23. use Illuminate\Support\Facades\Schema;
  24. use Illuminate\Translation\ArrayLoader;
  25. use Illuminate\Translation\Translator;
  26. use Illuminate\Validation\Factory;
  27. use PHPUnit\Framework\TestCase;
  28. class AgentApplicationTest extends TestCase
  29. {
  30. private Application $app;
  31. private AgentService $agents;
  32. private AgentApplicationService $service;
  33. protected function setUp(): void
  34. {
  35. Facade::clearResolvedInstances();
  36. $this->app = new Application(dirname(__DIR__, 2));
  37. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  38. Facade::setFacadeApplication($this->app);
  39. $db = new Manager($this->app);
  40. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  41. $db->setAsGlobal(); $db->bootEloquent();
  42. $this->app->instance('db', $db->getDatabaseManager());
  43. $this->app->bind('db.schema', fn () => $db->schema());
  44. $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));
  45. $this->app->instance('log', new \Psr\Log\NullLogger());
  46. $translator = new Translator(new ArrayLoader(), 'zh');
  47. $this->app->instance('translator', $translator);
  48. $validator = new Factory($translator, $this->app);
  49. $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
  50. $this->app->instance('validator', $validator);
  51. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
  52. public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
  53. });
  54. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  55. Schema::create('users', function (Blueprint $t) {
  56. $t->id(); $t->string('user_id')->unique(); $t->string('member_id')->unique(); $t->string('account');
  57. $t->string('username')->default(''); $t->string('first_name')->default(''); $t->integer('from')->default(1);
  58. $t->integer('is_banned')->default(0); $t->dateTime('delete_time')->nullable(); $t->string('language')->nullable(); $t->timestamps();
  59. });
  60. // Metadata only: the existing profit-profile migration inspects MySQL
  61. // indexes; the application and all business queries still use real SQLite.
  62. DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
  63. DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
  64. 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)');
  65. foreach (['2026_08_24_140000_create_agent_system_tables.php', '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
  66. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  67. }
  68. Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
  69. $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
  70. foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
  71. });
  72. Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
  73. $t->id(); $t->unsignedBigInteger('agent_id'); $t->unsignedBigInteger('flow_commission_profile_id');
  74. $t->string('profile_name'); $t->date('effective_from'); $t->unsignedBigInteger('created_by')->nullable();
  75. foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
  76. $t->timestamps(); $t->unique(['agent_id', 'effective_from']);
  77. });
  78. Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable());
  79. foreach (['agent_flow_commission_profiles', 'agent_profit_commission_profiles'] as $table) Schema::table($table, fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
  80. DB::table('agent_flow_commission_profiles')->insert(['name' => 'Default', 'is_default' => 1]);
  81. Schema::create('operation_audits', function (Blueprint $t) {
  82. $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action'); $t->json('changes');
  83. $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
  84. });
  85. 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'] as $file) {
  86. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  87. }
  88. foreach ([11 => 'zh', 22 => 'en', 33 => null] as $id => $language) DB::table('users')->insert([
  89. 'id' => $id, 'user_id' => (string) (10000 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
  90. 'first_name' => 'Member ' . $id, 'from' => 1, 'language' => $language, 'agent_id' => 99,
  91. ]);
  92. $this->agents = new AgentService(new AgentFlowCommissionAssignmentService(), new AgentProfitCommissionAssignmentService());
  93. $this->service = new AgentApplicationService();
  94. $this->request();
  95. }
  96. protected function tearDown(): void
  97. {
  98. DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
  99. }
  100. private function request(array $params = []): void
  101. {
  102. $request = Request::create('/admin/agent/applicationAudit', 'POST', $params);
  103. $request->user = (object) ['id' => 1, 'username' => 'admin'];
  104. $this->app->instance('request', $request);
  105. }
  106. private function application(int $userId = 11, string $username = 'newagent'): AgentApplication
  107. {
  108. return AgentApplication::create([
  109. 'user_id' => $userId, 'pending_user_id' => $userId, 'username' => $username, 'pending_username' => $username,
  110. 'password_hash' => password_hash('login-secret', PASSWORD_BCRYPT, ['cost' => 4]),
  111. 'withdraw_password_hash' => password_hash('withdraw-secret', PASSWORD_BCRYPT, ['cost' => 4]), 'status' => 'pending',
  112. ]);
  113. }
  114. private function audit(int $id, string $status = 'approved', array $extra = []): array
  115. {
  116. $this->request(['id' => $id, 'status' => $status] + $extra);
  117. return (new AdminApplication())->audit($this->service, $this->agents)->getData(true);
  118. }
  119. public function test_approval_creates_bound_first_level_agent_keeps_passwords_and_sends_one_private_message(): void
  120. {
  121. $application = $this->application();
  122. $response = $this->audit($application->id);
  123. $this->assertSame(0, $response['code'], $response['msg']);
  124. $agent = Agent::firstOrFail();
  125. $this->assertSame(11, $agent->owner_user_id);
  126. $this->assertSame(1, $agent->level);
  127. $this->assertSame(1, $agent->status);
  128. $this->assertTrue(Hash::check('login-secret', $agent->password));
  129. $this->assertTrue(Hash::check('withdraw-secret', $agent->withdraw_password));
  130. $this->assertSame(99, (int) DB::table('users')->where('id', 11)->value('agent_id'));
  131. $this->assertNotNull($agent->flow_commission_profile_id);
  132. $this->assertNotNull($agent->profit_commission_profile_id);
  133. $application->refresh();
  134. $this->assertSame('approved', $application->status);
  135. $this->assertNull($application->password_hash); $this->assertNull($application->withdraw_password_hash);
  136. $this->assertNull($application->pending_user_id); $this->assertNull($application->pending_username);
  137. $inbox = new SiteInboxService();
  138. $message = DB::table('site_messages')->first();
  139. $this->assertStringContainsString('newagent', $inbox->detail('user', 11, $message->id)['content']);
  140. $this->assertSame(1, $inbox->unread('user', 11)['count']);
  141. $this->assertSame(0, $inbox->unread('user', 22)['count']);
  142. $this->assertSame(1, DB::table('site_realtime_events')->count());
  143. $this->assertSame(0, $this->audit($application->id)['code']);
  144. $this->assertSame(1, Agent::count()); $this->assertSame(1, DB::table('site_messages')->count());
  145. $this->assertNotSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Cannot reverse'])['code']);
  146. $payload = json_encode($response) . DB::table('operation_audits')->get()->toJson() . DB::table('site_message_translations')->get()->toJson();
  147. foreach (['login-secret', 'withdraw-secret', 'password_hash', 'withdraw_password_hash'] as $secret) $this->assertStringNotContainsString($secret, $payload);
  148. }
  149. public function test_rejection_requires_reason_releases_pending_keys_and_notifies_in_member_language(): void
  150. {
  151. $application = $this->application(22);
  152. $this->assertNotSame(0, $this->audit($application->id, 'rejected')['code']);
  153. $this->assertSame('pending', $application->refresh()->status);
  154. $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
  155. $this->assertSame(0, Agent::count());
  156. $this->assertSame('rejected', $application->refresh()->status);
  157. $this->assertNull($application->pending_username); $this->assertNull($application->pending_user_id);
  158. $receipt = DB::table('site_message_recipients')->first();
  159. $this->assertSame('en', $receipt->language); $this->assertSame(22, (int) $receipt->recipient_id);
  160. $content = (new SiteInboxService())->detail('user', 22, $receipt->message_id)['content'];
  161. $this->assertStringContainsString('Please update your information', $content);
  162. $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
  163. $this->assertSame(1, DB::table('site_messages')->count());
  164. $this->application(22);
  165. $this->assertSame(2, AgentApplication::count());
  166. }
  167. public function test_notification_failure_rolls_back_agent_approval_and_profile_assignments(): void
  168. {
  169. $application = $this->application();
  170. DB::statement("CREATE TRIGGER reject_agent_notice BEFORE INSERT ON bot_site_messages BEGIN SELECT RAISE(ABORT, 'notice failure'); END");
  171. try {
  172. $this->service->audit(['id' => $application->id, 'status' => 'approved'], $this->agents);
  173. $this->fail('Notification failure must roll back approval');
  174. } catch (QueryException $e) { $this->assertStringContainsString('notice failure', $e->getMessage()); }
  175. $this->assertSame(0, Agent::count());
  176. $this->assertSame(0, DB::table('agent_flow_commission_assignments')->count());
  177. $this->assertSame(0, DB::table('agent_profit_commission_assignments')->count());
  178. $this->assertSame('pending', $application->refresh()->status);
  179. $this->assertNotNull($application->password_hash); $this->assertSame(11, (int) $application->pending_user_id);
  180. $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::transactionLevel());
  181. }
  182. public function test_existing_agents_can_be_bound_explicitly_but_identity_cannot_be_reassigned(): void
  183. {
  184. $agent = $this->agents->create(['username' => 'legacyagent', 'password' => 'legacy-secret']);
  185. $this->assertSame(11, $this->service->bindMember($agent, 11)->owner_user_id);
  186. $this->assertSame(11, $this->service->bindMember($agent, 11)->owner_user_id);
  187. try { $this->service->bindMember($agent, 22); $this->fail('Agent identity cannot be reassigned'); }
  188. catch (\RuntimeException $e) { $this->assertStringContainsString('已绑定', $e->getMessage()); }
  189. $another = $this->agents->create(['username' => 'anotheragent', 'password' => 'legacy-secret']);
  190. try { $this->service->bindMember($another, 11); $this->fail('One member cannot own two agents'); }
  191. catch (\RuntimeException $e) { $this->assertStringContainsString('已绑定', $e->getMessage()); }
  192. $this->assertNull($another->refresh()->owner_user_id);
  193. }
  194. public function test_listing_hides_credentials_and_unknown_member_language_gets_a_private_fallback_notice(): void
  195. {
  196. $application = $this->application(33);
  197. $listing = $this->service->listing(['status' => 'pending', 'member_id' => 'M33']);
  198. $this->assertSame(1, $listing['total']);
  199. $this->assertSame('member33', $listing['data'][0]->member_account);
  200. $this->assertStringNotContainsString('password', json_encode($listing));
  201. $this->assertSame(0, $this->audit($application->id)['code']);
  202. $this->assertSame('zh', DB::table('site_message_recipients')->value('language'));
  203. $this->assertSame(1, (new SiteInboxService())->unread('user', 33)['count']);
  204. }
  205. public function test_admin_update_binds_existing_identity_and_rolls_back_an_attempted_reassignment(): void
  206. {
  207. $agent = $this->agents->create(['username' => 'oldaccount', 'password' => 'legacy-secret', 'real_name' => 'Original']);
  208. $this->request(['id' => $agent->id, 'owner_user_id' => 11]);
  209. $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
  210. $this->assertSame(0, $response['code'], $response['msg']);
  211. $this->assertSame(11, $response['data']['owner_user_id']);
  212. $this->assertArrayHasKey('flow_commission_profile', $response['data']);
  213. $this->request(['id' => $agent->id, 'owner_user_id' => 22, 'real_name' => 'Unexpected']);
  214. $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
  215. $this->assertNotSame(0, $response['code']);
  216. $this->assertSame('Original', $agent->refresh()->real_name);
  217. $this->assertSame(11, $agent->owner_user_id);
  218. }
  219. public function test_application_routes_keep_the_existing_admin_auth_and_agent_management_restriction(): void
  220. {
  221. $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
  222. $this->app->instance('router', $router);
  223. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  224. foreach ([['/admin/agent/applications', 'GET'], ['/admin/agent/applicationAudit', 'POST']] as [$path, $method]) {
  225. $route = $router->getRoutes()->match(Request::create($path, $method));
  226. $this->assertContains('admin.jwt', $route->gatherMiddleware());
  227. $this->assertContains('agent.admin', $route->gatherMiddleware());
  228. }
  229. $this->request(); request()->user = (object) ['id' => 20, 'username' => 'ordinary-admin'];
  230. $called = false;
  231. $response = (new \App\Http\Middleware\AgentAdminMiddleware())->handle(request(), function () use (&$called) { $called = true; });
  232. $this->assertFalse($called); $this->assertNotSame(0, $response->getData(true)['code']);
  233. }
  234. public function test_newly_unavailable_account_or_username_cannot_be_approved(): void
  235. {
  236. $application = $this->application();
  237. DB::table('users')->where('id', 11)->update(['is_banned' => 1]);
  238. $this->assertNotSame(0, $this->audit($application->id)['code']);
  239. $this->assertSame('pending', $application->refresh()->status);
  240. $this->assertSame(0, Agent::count());
  241. $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => '账号已停用'])['code']);
  242. $this->assertSame(1, DB::table('site_messages')->count());
  243. $other = $this->application(22, 'takenname');
  244. $this->agents->create(['username' => 'TakenName', 'password' => 'existing-secret']);
  245. $response = $this->audit($other->id);
  246. $this->assertNotSame(0, $response['code']);
  247. $this->assertStringContainsString('账号已被使用', $response['msg']);
  248. $this->assertSame('pending', $other->refresh()->status);
  249. $this->assertSame(1, Agent::count());
  250. $this->assertSame(1, DB::table('site_messages')->count());
  251. }
  252. }