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()); } }