load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use plugin\saiadmin\app\model\water\{WaterModel,WaterAgent,WaterEnterprise,WaterOrder,WaterOperator,WaterOperatorRole,WaterLedger}; use plugin\saiadmin\app\service\{WaterBusiness,WaterAgentDashboard,WaterAgentManagement}; use plugin\saiadmin\app\controller\water\WaterController; use plugin\saiadmin\exception\ApiException; $checks=0; function check(bool $ok,string $label): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($label); } function rejects(callable $fn,string $label): void { try {$fn();} catch (ApiException $e) {check(true,$label);return;} throw new RuntimeException($label); } function tableModel(string $table) { return (new class extends WaterModel {})->setTable($table)->newQuery(); } $now=new DateTimeImmutable('2026-09-16 12:00:00',new DateTimeZone('Asia/Shanghai')); $dashboard=new WaterAgentDashboard($now); $management=new WaterAgentManagement(); $business=new WaterBusiness(); $tag='AGT-'.bin2hex(random_bytes(4)); $db=(new WaterAgent())->getConnection(); $db->beginTransaction(); try { $before=$dashboard->dashboard([]); $ids=[]; foreach (['root','child','leaf','unset','disabled','other'] as $i=>$name) { $parent=match($name) {'root','disabled','other'=>0,'child'=>$ids['root'],'leaf'=>$ids['child'],default=>null}; $ids[$name]=$management->save(['name'=>$tag.'-'.$name,'contact'=>'Contact','phone'=>'TEST-'.$i,'region'=>'测试辖区', 'parent_id'=>$parent,'status'=>$name==='disabled'?2:1],1)['id']; WaterAgent::query()->where('id',$ids[$name])->update(['created_at'=>'2026-09-01 00:00:00']); } $first=$dashboard->dashboard(['keyword'=>' '.$tag.' ','limit'=>5]); $second=$dashboard->dashboard(['keyword'=>$tag,'limit'=>5,'page'=>2]); check($first['list']['total']===6 && count($first['list']['data'])===5 && count($second['list']['data'])===1,'pagination'); check(array_column(array_merge($first['list']['data'],$second['list']['data']),'id')===array_reverse(array_values($ids)),'stable order'); check($first['summary']===$second['summary'],'global cards independent of pages'); check($first['summary']['total']-$before['summary']['total']===6,'agent total delta'); check($first['summary']['new_month']-$before['summary']['new_month']===6,'month new delta'); check($first['summary']['members']-$before['summary']['members']===2,'team members counted once'); foreach (['1'=>3,'2'=>1,'3'=>1,'unset'=>1] as $level=>$count) check($dashboard->dashboard(['keyword'=>$tag,'level'=>(string)$level])['list']['total']===$count,'level filter '.$level); check($dashboard->dashboard(['keyword'=>$tag,'authorization'=>'pending'])['list']['total']===5,'enabled archive is pending, not authorized'); check($dashboard->dashboard(['keyword'=>$tag,'authorization'=>'disabled'])['list']['total']===1,'disabled filter'); check($dashboard->dashboard(['keyword'=>'TEST-5'])['list']['total']>=1,'phone search'); $save=function(string $name,$parent) use ($ids,$management) { return $management->save(array_replace(WaterAgent::query()->find($ids[$name])->toArray(),['parent_id'=>$parent]),1); }; rejects(fn()=>$save('root',$ids['leaf']),'cycle rejected'); rejects(fn()=>$save('root',$ids['other']),'subtree exceeding three levels rejected'); rejects(fn()=>$save('child',$ids['child']),'self parent rejected'); rejects(fn()=>$save('child',null),'parent with children cannot become unknown'); rejects(fn()=>$save('leaf',$ids['unset']),'unknown parent level rejected'); rejects(fn()=>$save('leaf',$ids['disabled']),'disabled parent rejected'); rejects(fn()=>$save('leaf',99999999),'missing parent rejected'); check($dashboard->detail($ids['leaf'])['agent']['level']===3,'invalid reparent left hierarchy intact'); $save('leaf',$ids['root']); check($dashboard->detail($ids['leaf'])['agent']['level']===2,'reparent recalculates level'); $save('leaf',$ids['child']); $enterprises=[]; foreach (['root','child','other'] as $name) $enterprises[$name]=$business->save('enterprise',['name'=>$tag.'-company-'.$name,'contact'=>'TEST','phone'=>'TEST','address'=>'TEST','agent_id'=>$ids[$name]],1)['id']; $orders=[['root','2026-08-31 23:59:59',1000],['root','2026-09-01 00:00:00',2000],['root','2026-09-16 12:00:00',3000],['child','2026-09-10 12:00:00',7000],['other','2026-09-17 00:00:00',99000]]; foreach ($orders as $i=>[$name,$at,$amount]) WaterOrder::query()->insert(['enterprise_id'=>$enterprises[$name],'package_id'=>0,'sku_id'=>0,'package_name'=>'TEST','buckets'=>1,'amount_cents'=>$amount,'reference'=>$tag.'-order-'.$i,'created_by'=>1,'created_at'=>$at]); $data=$dashboard->dashboard(['keyword'=>$tag,'limit'=>100]); $rows=array_column($data['list']['data'],null,'id'); check($rows[$ids['root']]['sales_cents']===6000 && $rows[$ids['root']]['month_cents']===5000,'month boundary and lifetime sales'); check($rows[$ids['child']]['month_cents']===7000 && $rows[$ids['other']]['month_cents']===0,'future sales excluded'); check($rows[$ids['root']]['enterprise_count']===1,'orders do not multiply enterprise count'); check($data['summary']['sales_cents']-$before['summary']['sales_cents']===13000,'global sales delta'); check($data['summary']['active_members']-$before['summary']['active_members']===1,'active member has real monthly sales'); $rankIds=array_column($data['ranking'],'id'); check(array_search($ids['child'],$rankIds,true)detail($ids['root']); check(count($detail['children'])===1 && count($detail['enterprises'])===1,'detail direct team and enterprises'); $password='Test-'.bin2hex(random_bytes(12)); $input=['id'=>$ids['root'],'username'=>$tag.'-user','password'=>$password]; rejects(fn()=>$management->openAccount($input,101),'non-superuser cannot open account'); rejects(fn()=>$management->openAccount(array_replace($input,['password'=>'123']),1),'short password rejected'); $account=$management->openAccount($input,1); $uid=$account['user_id']; $user=WaterOperator::query()->find($uid); check(password_verify($password,$user->getRawOriginal('password')),'password hashed'); check(!array_key_exists('password',$user->toArray()),'model password hidden'); check(WaterOperatorRole::query()->where('user_id',$uid)->count()===0 && (int)$user->is_super===0 && $user->dept_id===null,'no automatic roles or scope'); check($dashboard->detail($ids['root'])['agent']['authorization']==='pending','account without roles is pending'); rejects(fn()=>$management->openAccount(array_replace($input,['username'=>$tag.'-new']),1),'agent account duplicate rejected'); rejects(fn()=>$management->openAccount(array_replace($input,['id'=>$ids['child']]),1),'duplicate username rejected'); // A valid water permission is required before an active linked account can be shown authorized. $role=tableModel('sa_system_role')->insertGetId(['name'=>$tag,'code'=>$tag,'status'=>1,'data_scope'=>4]); $menu=tableModel('sa_system_menu')->where('slug','water:agents:read')->whereNull('delete_time')->value('id'); check((bool)$menu,'existing water permission'); tableModel('sa_system_role_menu')->insert(['role_id'=>$role,'menu_id'=>$menu]); WaterOperatorRole::query()->insert(['user_id'=>$uid,'role_id'=>$role]); check($dashboard->detail($ids['root'])['agent']['authorization']==='authorized','live permission authorizes display'); $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,$uid); $request=new \support\Request("GET /core/water/agents/dashboard HTTP/1.1\r\nHost: localhost\r\n\r\n"); check(json_decode($controller->agentDashboard($request)->rawBody(),true)['code']===200,'authorized read'); WaterAgent::query()->where('id',$ids['root'])->update(['status'=>2]); rejects(fn()=>$controller->agentDashboard($request),'disabled agent blocked despite role'); check($dashboard->detail($ids['root'])['agent']['authorization']==='disabled','disabled agent display'); WaterAgent::query()->where('id',$ids['root'])->update(['status'=>1]); tableModel('sa_system_role')->where('id',$role)->update(['status'=>0]); rejects(fn()=>$controller->agentDashboard($request),'disabled role immediately blocked'); check($dashboard->detail($ids['root'])['agent']['authorization']==='pending','disabled role removes display authorization'); WaterOperator::query()->where('id',$uid)->update(['status'=>2]); check($dashboard->detail($ids['root'])['agent']['authorization']==='disabled','disabled linked account'); $reflection->getProperty('adminId')->setValue($controller,99999999); rejects(fn()=>$controller->agentDashboard($request),'dashboard unauthorized'); rejects(fn()=>$controller->agentDetail($request),'detail unauthorized'); $body=json_encode($input); $post=new \support\Request("POST /core/water/agents/open-account HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n".$body); $response=$controller->agentOpenAccount($post)->rawBody(); check(!str_contains($response,$password) && (json_decode($response,true)['code']??0)!==200,'opening error does not echo credentials'); check(!str_contains(serialize(WaterLedger::query()->where('reference','agent:'.$ids['root'])->get()->toArray()),$password),'audit excludes password'); foreach ([['keyword'=>[]],['keyword'=>str_repeat('字',121)],['level'=>'9'],['authorization'=>'bad']] as $input) rejects(fn()=>$dashboard->dashboard($input),'invalid filters'); rejects(fn()=>$dashboard->detail(99999999),'missing detail'); $roleless=['name'=>$tag.'-new','contact'=>'TEST','phone'=>'TEST','region'=>'TEST','parent_id'=>0,'username'=>$tag.'-new-user','password'=>$password]; $created=$management->openAccount($roleless,1); check((int)WaterAgent::query()->find($created['id'])->user_id===$created['user_id'],'new archive and account created atomically'); $agentCount=WaterAgent::query()->count(); rejects(fn()=>$management->openAccount(array_replace($roleless,['username'=>$tag.'-rollback','phone'=>str_repeat('x',21)]),1),'account validation rollback'); check(WaterAgent::query()->count()===$agentCount && !WaterOperator::query()->where('username',$tag.'-rollback')->exists(),'no orphan archive on opening failure'); } finally { $db->rollBack(); } check(!WaterAgent::query()->where('name','like',$tag.'%')->exists() && !WaterOperator::query()->where('username','like',$tag.'%')->exists(),'all fixtures rolled back'); if (in_array('--http',$argv,true)) { $token=\Tinywan\Jwt\JwtToken::generateToken(['id'=>1,'username'=>'admin','plat'=>'saiadmin','type'=>'web','client'=>$tag,'access_exp'=>120,'refresh_exp'=>120])['access_token']; $id=WaterAgent::query()->orderBy('id')->value('id'); foreach (['dashboard?limit=1','detail?id='.$id] as $path) { $ch=curl_init('http://127.0.0.1:8787/core/water/agents/'.$path); curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>15,CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$token]]); $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); $body=json_decode((string)$raw,true); check($status===200 && ($body['code']??0)===200,'HTTP '.$path); check(!str_contains((string)$raw,'"password"'),'HTTP excludes password'); } } echo "PASS: $checks agent checks; all fixtures rolled back.\n";