|
@@ -0,0 +1,269 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+namespace Tests\Integration;
|
|
|
|
|
+
|
|
|
|
|
+use App\Http\Controllers\admin\AgentApplication as AdminApplication;
|
|
|
|
|
+use App\Models\Agent\Agent;
|
|
|
|
|
+use App\Models\Agent\AgentApplication;
|
|
|
|
|
+use App\Models\Agent\AgentFlowCommissionProfile;
|
|
|
|
|
+use App\Services\Agent\AgentApplicationService;
|
|
|
|
|
+use App\Services\Agent\AgentFlowCommissionAssignmentService;
|
|
|
|
|
+use App\Services\Agent\AgentProfitCommissionAssignmentService;
|
|
|
|
|
+use App\Services\Agent\AgentService;
|
|
|
|
|
+use App\Services\SiteInboxService;
|
|
|
|
|
+use Illuminate\Config\Repository;
|
|
|
|
|
+use Illuminate\Database\Capsule\Manager;
|
|
|
|
|
+use Illuminate\Database\QueryException;
|
|
|
|
|
+use Illuminate\Database\Schema\Blueprint;
|
|
|
|
|
+use Illuminate\Foundation\Application;
|
|
|
|
|
+use Illuminate\Hashing\BcryptHasher;
|
|
|
|
|
+use Illuminate\Http\JsonResponse;
|
|
|
|
|
+use Illuminate\Http\Request;
|
|
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
|
|
+use Illuminate\Support\Facades\Facade;
|
|
|
|
|
+use Illuminate\Support\Facades\Hash;
|
|
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
|
|
+use Illuminate\Translation\ArrayLoader;
|
|
|
|
|
+use Illuminate\Translation\Translator;
|
|
|
|
|
+use Illuminate\Validation\Factory;
|
|
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
|
|
+
|
|
|
|
|
+class AgentApplicationTest extends TestCase
|
|
|
|
|
+{
|
|
|
|
|
+ private Application $app;
|
|
|
|
|
+ private AgentService $agents;
|
|
|
|
|
+ private AgentApplicationService $service;
|
|
|
|
|
+
|
|
|
|
|
+ protected function setUp(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ Facade::clearResolvedInstances();
|
|
|
|
|
+ $this->app = new Application(dirname(__DIR__, 2));
|
|
|
|
|
+ $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
|
|
|
|
|
+ Facade::setFacadeApplication($this->app);
|
|
|
|
|
+ $db = new Manager($this->app);
|
|
|
|
|
+ $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
|
|
|
|
|
+ $db->setAsGlobal(); $db->bootEloquent();
|
|
|
|
|
+ $this->app->instance('db', $db->getDatabaseManager());
|
|
|
|
|
+ $this->app->bind('db.schema', fn () => $db->schema());
|
|
|
|
|
+ $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));
|
|
|
|
|
+ $this->app->instance('log', new \Psr\Log\NullLogger());
|
|
|
|
|
+ $translator = new Translator(new ArrayLoader(), 'zh');
|
|
|
|
|
+ $this->app->instance('translator', $translator);
|
|
|
|
|
+ $validator = new Factory($translator, $this->app);
|
|
|
|
|
+ $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
|
|
|
|
|
+ $this->app->instance('validator', $validator);
|
|
|
|
|
+ $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
|
|
|
|
|
+ public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
|
|
|
|
|
+ });
|
|
|
|
|
+ Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
|
|
|
|
|
+ Schema::create('users', function (Blueprint $t) {
|
|
|
|
|
+ $t->id(); $t->string('user_id')->unique(); $t->string('member_id')->unique(); $t->string('account');
|
|
|
|
|
+ $t->string('username')->default(''); $t->string('first_name')->default(''); $t->integer('from')->default(1);
|
|
|
|
|
+ $t->integer('is_banned')->default(0); $t->dateTime('delete_time')->nullable(); $t->string('language')->nullable(); $t->timestamps();
|
|
|
|
|
+ });
|
|
|
|
|
+ // Metadata only: the existing profit-profile migration inspects MySQL
|
|
|
|
|
+ // indexes; the application and all business queries still use real SQLite.
|
|
|
|
|
+ DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
|
|
|
|
|
+ DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
|
|
|
|
|
+ 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)');
|
|
|
|
|
+ foreach (['2026_08_24_140000_create_agent_system_tables.php', '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
|
|
|
|
|
+ (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
|
|
|
|
|
+ }
|
|
|
|
|
+ Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
|
|
|
|
|
+ $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
|
|
|
|
|
+ foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
|
|
|
|
|
+ });
|
|
|
|
|
+ Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
|
|
|
|
|
+ $t->id(); $t->unsignedBigInteger('agent_id'); $t->unsignedBigInteger('flow_commission_profile_id');
|
|
|
|
|
+ $t->string('profile_name'); $t->date('effective_from'); $t->unsignedBigInteger('created_by')->nullable();
|
|
|
|
|
+ foreach (AgentFlowCommissionProfile::RATE_FIELDS as $field) $t->decimal($field, 8, 4)->default(0);
|
|
|
|
|
+ $t->timestamps(); $t->unique(['agent_id', 'effective_from']);
|
|
|
|
|
+ });
|
|
|
|
|
+ Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable());
|
|
|
|
|
+ foreach (['agent_flow_commission_profiles', 'agent_profit_commission_profiles'] as $table) Schema::table($table, fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
|
|
|
|
|
+ DB::table('agent_flow_commission_profiles')->insert(['name' => 'Default', 'is_default' => 1]);
|
|
|
|
|
+ Schema::create('operation_audits', function (Blueprint $t) {
|
|
|
|
|
+ $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action'); $t->json('changes');
|
|
|
|
|
+ $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
|
|
|
|
|
+ });
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
|
|
|
|
|
+ }
|
|
|
|
|
+ foreach ([11 => 'zh', 22 => 'en', 33 => null] as $id => $language) DB::table('users')->insert([
|
|
|
|
|
+ 'id' => $id, 'user_id' => (string) (10000 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
|
|
|
|
|
+ 'first_name' => 'Member ' . $id, 'from' => 1, 'language' => $language, 'agent_id' => 99,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $this->agents = new AgentService(new AgentFlowCommissionAssignmentService(), new AgentProfitCommissionAssignmentService());
|
|
|
|
|
+ $this->service = new AgentApplicationService();
|
|
|
|
|
+ $this->request();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function tearDown(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function request(array $params = []): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $request = Request::create('/admin/agent/applicationAudit', 'POST', $params);
|
|
|
|
|
+ $request->user = (object) ['id' => 1, 'username' => 'admin'];
|
|
|
|
|
+ $this->app->instance('request', $request);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function application(int $userId = 11, string $username = 'newagent'): AgentApplication
|
|
|
|
|
+ {
|
|
|
|
|
+ return AgentApplication::create([
|
|
|
|
|
+ 'user_id' => $userId, 'pending_user_id' => $userId, 'username' => $username, 'pending_username' => $username,
|
|
|
|
|
+ 'password_hash' => password_hash('login-secret', PASSWORD_BCRYPT, ['cost' => 4]),
|
|
|
|
|
+ 'withdraw_password_hash' => password_hash('withdraw-secret', PASSWORD_BCRYPT, ['cost' => 4]), 'status' => 'pending',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function audit(int $id, string $status = 'approved', array $extra = []): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->request(['id' => $id, 'status' => $status] + $extra);
|
|
|
|
|
+ return (new AdminApplication())->audit($this->service, $this->agents)->getData(true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_approval_creates_bound_first_level_agent_keeps_passwords_and_sends_one_private_message(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $application = $this->application();
|
|
|
|
|
+ $response = $this->audit($application->id);
|
|
|
|
|
+ $this->assertSame(0, $response['code'], $response['msg']);
|
|
|
|
|
+ $agent = Agent::firstOrFail();
|
|
|
|
|
+ $this->assertSame(11, $agent->owner_user_id);
|
|
|
|
|
+ $this->assertSame(1, $agent->level);
|
|
|
|
|
+ $this->assertSame(1, $agent->status);
|
|
|
|
|
+ $this->assertTrue(Hash::check('login-secret', $agent->password));
|
|
|
|
|
+ $this->assertTrue(Hash::check('withdraw-secret', $agent->withdraw_password));
|
|
|
|
|
+ $this->assertSame(99, (int) DB::table('users')->where('id', 11)->value('agent_id'));
|
|
|
|
|
+ $this->assertNotNull($agent->flow_commission_profile_id);
|
|
|
|
|
+ $this->assertNotNull($agent->profit_commission_profile_id);
|
|
|
|
|
+ $application->refresh();
|
|
|
|
|
+ $this->assertSame('approved', $application->status);
|
|
|
|
|
+ $this->assertNull($application->password_hash); $this->assertNull($application->withdraw_password_hash);
|
|
|
|
|
+ $this->assertNull($application->pending_user_id); $this->assertNull($application->pending_username);
|
|
|
|
|
+ $inbox = new SiteInboxService();
|
|
|
|
|
+ $message = DB::table('site_messages')->first();
|
|
|
|
|
+ $this->assertStringContainsString('newagent', $inbox->detail('user', 11, $message->id)['content']);
|
|
|
|
|
+ $this->assertSame(1, $inbox->unread('user', 11)['count']);
|
|
|
|
|
+ $this->assertSame(0, $inbox->unread('user', 22)['count']);
|
|
|
|
|
+ $this->assertSame(1, DB::table('site_realtime_events')->count());
|
|
|
|
|
+ $this->assertSame(0, $this->audit($application->id)['code']);
|
|
|
|
|
+ $this->assertSame(1, Agent::count()); $this->assertSame(1, DB::table('site_messages')->count());
|
|
|
|
|
+ $this->assertNotSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Cannot reverse'])['code']);
|
|
|
|
|
+ $payload = json_encode($response) . DB::table('operation_audits')->get()->toJson() . DB::table('site_message_translations')->get()->toJson();
|
|
|
|
|
+ foreach (['login-secret', 'withdraw-secret', 'password_hash', 'withdraw_password_hash'] as $secret) $this->assertStringNotContainsString($secret, $payload);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_rejection_requires_reason_releases_pending_keys_and_notifies_in_member_language(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $application = $this->application(22);
|
|
|
|
|
+ $this->assertNotSame(0, $this->audit($application->id, 'rejected')['code']);
|
|
|
|
|
+ $this->assertSame('pending', $application->refresh()->status);
|
|
|
|
|
+ $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
|
|
|
|
|
+ $this->assertSame(0, Agent::count());
|
|
|
|
|
+ $this->assertSame('rejected', $application->refresh()->status);
|
|
|
|
|
+ $this->assertNull($application->pending_username); $this->assertNull($application->pending_user_id);
|
|
|
|
|
+ $receipt = DB::table('site_message_recipients')->first();
|
|
|
|
|
+ $this->assertSame('en', $receipt->language); $this->assertSame(22, (int) $receipt->recipient_id);
|
|
|
|
|
+ $content = (new SiteInboxService())->detail('user', 22, $receipt->message_id)['content'];
|
|
|
|
|
+ $this->assertStringContainsString('Please update your information', $content);
|
|
|
|
|
+ $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => 'Please update your information'])['code']);
|
|
|
|
|
+ $this->assertSame(1, DB::table('site_messages')->count());
|
|
|
|
|
+ $this->application(22);
|
|
|
|
|
+ $this->assertSame(2, AgentApplication::count());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_notification_failure_rolls_back_agent_approval_and_profile_assignments(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $application = $this->application();
|
|
|
|
|
+ DB::statement("CREATE TRIGGER reject_agent_notice BEFORE INSERT ON bot_site_messages BEGIN SELECT RAISE(ABORT, 'notice failure'); END");
|
|
|
|
|
+ try {
|
|
|
|
|
+ $this->service->audit(['id' => $application->id, 'status' => 'approved'], $this->agents);
|
|
|
|
|
+ $this->fail('Notification failure must roll back approval');
|
|
|
|
|
+ } catch (QueryException $e) { $this->assertStringContainsString('notice failure', $e->getMessage()); }
|
|
|
|
|
+ $this->assertSame(0, Agent::count());
|
|
|
|
|
+ $this->assertSame(0, DB::table('agent_flow_commission_assignments')->count());
|
|
|
|
|
+ $this->assertSame(0, DB::table('agent_profit_commission_assignments')->count());
|
|
|
|
|
+ $this->assertSame('pending', $application->refresh()->status);
|
|
|
|
|
+ $this->assertNotNull($application->password_hash); $this->assertSame(11, (int) $application->pending_user_id);
|
|
|
|
|
+ $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::transactionLevel());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_existing_agents_can_be_bound_explicitly_but_identity_cannot_be_reassigned(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $agent = $this->agents->create(['username' => 'legacyagent', 'password' => 'legacy-secret']);
|
|
|
|
|
+ $this->assertSame(11, $this->service->bindMember($agent, 11)->owner_user_id);
|
|
|
|
|
+ $this->assertSame(11, $this->service->bindMember($agent, 11)->owner_user_id);
|
|
|
|
|
+ try { $this->service->bindMember($agent, 22); $this->fail('Agent identity cannot be reassigned'); }
|
|
|
|
|
+ catch (\RuntimeException $e) { $this->assertStringContainsString('已绑定', $e->getMessage()); }
|
|
|
|
|
+ $another = $this->agents->create(['username' => 'anotheragent', 'password' => 'legacy-secret']);
|
|
|
|
|
+ try { $this->service->bindMember($another, 11); $this->fail('One member cannot own two agents'); }
|
|
|
|
|
+ catch (\RuntimeException $e) { $this->assertStringContainsString('已绑定', $e->getMessage()); }
|
|
|
|
|
+ $this->assertNull($another->refresh()->owner_user_id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_listing_hides_credentials_and_unknown_member_language_gets_a_private_fallback_notice(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $application = $this->application(33);
|
|
|
|
|
+ $listing = $this->service->listing(['status' => 'pending', 'member_id' => 'M33']);
|
|
|
|
|
+ $this->assertSame(1, $listing['total']);
|
|
|
|
|
+ $this->assertSame('member33', $listing['data'][0]->member_account);
|
|
|
|
|
+ $this->assertStringNotContainsString('password', json_encode($listing));
|
|
|
|
|
+ $this->assertSame(0, $this->audit($application->id)['code']);
|
|
|
|
|
+ $this->assertSame('zh', DB::table('site_message_recipients')->value('language'));
|
|
|
|
|
+ $this->assertSame(1, (new SiteInboxService())->unread('user', 33)['count']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_admin_update_binds_existing_identity_and_rolls_back_an_attempted_reassignment(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $agent = $this->agents->create(['username' => 'oldaccount', 'password' => 'legacy-secret', 'real_name' => 'Original']);
|
|
|
|
|
+ $this->request(['id' => $agent->id, 'owner_user_id' => 11]);
|
|
|
|
|
+ $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
|
|
|
|
|
+ $this->assertSame(0, $response['code'], $response['msg']);
|
|
|
|
|
+ $this->assertSame(11, $response['data']['owner_user_id']);
|
|
|
|
|
+ $this->assertArrayHasKey('flow_commission_profile', $response['data']);
|
|
|
|
|
+ $this->request(['id' => $agent->id, 'owner_user_id' => 22, 'real_name' => 'Unexpected']);
|
|
|
|
|
+ $response = (new \App\Http\Controllers\admin\Agent())->update(request(), $this->agents)->getData(true);
|
|
|
|
|
+ $this->assertNotSame(0, $response['code']);
|
|
|
|
|
+ $this->assertSame('Original', $agent->refresh()->real_name);
|
|
|
|
|
+ $this->assertSame(11, $agent->owner_user_id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_application_routes_keep_the_existing_admin_auth_and_agent_management_restriction(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
|
|
|
|
|
+ $this->app->instance('router', $router);
|
|
|
|
|
+ $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
|
|
|
|
|
+ foreach ([['/admin/agent/applications', 'GET'], ['/admin/agent/applicationAudit', 'POST']] as [$path, $method]) {
|
|
|
|
|
+ $route = $router->getRoutes()->match(Request::create($path, $method));
|
|
|
|
|
+ $this->assertContains('admin.jwt', $route->gatherMiddleware());
|
|
|
|
|
+ $this->assertContains('agent.admin', $route->gatherMiddleware());
|
|
|
|
|
+ }
|
|
|
|
|
+ $this->request(); request()->user = (object) ['id' => 20, 'username' => 'ordinary-admin'];
|
|
|
|
|
+ $called = false;
|
|
|
|
|
+ $response = (new \App\Http\Middleware\AgentAdminMiddleware())->handle(request(), function () use (&$called) { $called = true; });
|
|
|
|
|
+ $this->assertFalse($called); $this->assertNotSame(0, $response->getData(true)['code']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_newly_unavailable_account_or_username_cannot_be_approved(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $application = $this->application();
|
|
|
|
|
+ DB::table('users')->where('id', 11)->update(['is_banned' => 1]);
|
|
|
|
|
+ $this->assertNotSame(0, $this->audit($application->id)['code']);
|
|
|
|
|
+ $this->assertSame('pending', $application->refresh()->status);
|
|
|
|
|
+ $this->assertSame(0, Agent::count());
|
|
|
|
|
+ $this->assertSame(0, $this->audit($application->id, 'rejected', ['remark' => '账号已停用'])['code']);
|
|
|
|
|
+ $this->assertSame(1, DB::table('site_messages')->count());
|
|
|
|
|
+
|
|
|
|
|
+ $other = $this->application(22, 'takenname');
|
|
|
|
|
+ $this->agents->create(['username' => 'TakenName', 'password' => 'existing-secret']);
|
|
|
|
|
+ $response = $this->audit($other->id);
|
|
|
|
|
+ $this->assertNotSame(0, $response['code']);
|
|
|
|
|
+ $this->assertStringContainsString('账号已被使用', $response['msg']);
|
|
|
|
|
+ $this->assertSame('pending', $other->refresh()->status);
|
|
|
|
|
+ $this->assertSame(1, Agent::count());
|
|
|
|
|
+ $this->assertSame(1, DB::table('site_messages')->count());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|