water_agent_dashboard_test.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. <?php
  2. /** Agent hierarchy, real sales, account opening and live authorization regression. Fixtures roll back. */
  3. require dirname(__DIR__).'/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. date_default_timezone_set('Asia/Shanghai');
  7. use plugin\saiadmin\app\model\water\{WaterModel,WaterAgent,WaterEnterprise,WaterOrder,WaterOperator,WaterOperatorRole,WaterLedger};
  8. use plugin\saiadmin\app\service\{WaterBusiness,WaterAgentDashboard,WaterAgentManagement};
  9. use plugin\saiadmin\app\controller\water\WaterController;
  10. use plugin\saiadmin\exception\ApiException;
  11. $checks=0;
  12. function check(bool $ok,string $label): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($label); }
  13. function rejects(callable $fn,string $label): void { try {$fn();} catch (ApiException $e) {check(true,$label);return;} throw new RuntimeException($label); }
  14. function tableModel(string $table) { return (new class extends WaterModel {})->setTable($table)->newQuery(); }
  15. $now=new DateTimeImmutable('2026-09-16 12:00:00',new DateTimeZone('Asia/Shanghai'));
  16. $dashboard=new WaterAgentDashboard($now); $management=new WaterAgentManagement(); $business=new WaterBusiness();
  17. $tag='AGT-'.bin2hex(random_bytes(4)); $db=(new WaterAgent())->getConnection(); $db->beginTransaction();
  18. try {
  19. $before=$dashboard->dashboard([]); $ids=[];
  20. foreach (['root','child','leaf','unset','disabled','other'] as $i=>$name) {
  21. $parent=match($name) {'root','disabled','other'=>0,'child'=>$ids['root'],'leaf'=>$ids['child'],default=>null};
  22. $ids[$name]=$management->save(['name'=>$tag.'-'.$name,'contact'=>'Contact','phone'=>'TEST-'.$i,'region'=>'测试辖区',
  23. 'parent_id'=>$parent,'status'=>$name==='disabled'?2:1],1)['id'];
  24. WaterAgent::query()->where('id',$ids[$name])->update(['created_at'=>'2026-09-01 00:00:00']);
  25. }
  26. $first=$dashboard->dashboard(['keyword'=>' '.$tag.' ','limit'=>5]); $second=$dashboard->dashboard(['keyword'=>$tag,'limit'=>5,'page'=>2]);
  27. check($first['list']['total']===6 && count($first['list']['data'])===5 && count($second['list']['data'])===1,'pagination');
  28. check(array_column(array_merge($first['list']['data'],$second['list']['data']),'id')===array_reverse(array_values($ids)),'stable order');
  29. check($first['summary']===$second['summary'],'global cards independent of pages');
  30. check($first['summary']['total']-$before['summary']['total']===6,'agent total delta');
  31. check($first['summary']['new_month']-$before['summary']['new_month']===6,'month new delta');
  32. check($first['summary']['members']-$before['summary']['members']===2,'team members counted once');
  33. 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);
  34. check($dashboard->dashboard(['keyword'=>$tag,'authorization'=>'pending'])['list']['total']===5,'enabled archive is pending, not authorized');
  35. check($dashboard->dashboard(['keyword'=>$tag,'authorization'=>'disabled'])['list']['total']===1,'disabled filter');
  36. check($dashboard->dashboard(['keyword'=>'TEST-5'])['list']['total']>=1,'phone search');
  37. $save=function(string $name,$parent) use ($ids,$management) {
  38. return $management->save(array_replace(WaterAgent::query()->find($ids[$name])->toArray(),['parent_id'=>$parent]),1);
  39. };
  40. rejects(fn()=>$save('root',$ids['leaf']),'cycle rejected');
  41. rejects(fn()=>$save('root',$ids['other']),'subtree exceeding three levels rejected');
  42. rejects(fn()=>$save('child',$ids['child']),'self parent rejected');
  43. rejects(fn()=>$save('child',null),'parent with children cannot become unknown');
  44. rejects(fn()=>$save('leaf',$ids['unset']),'unknown parent level rejected');
  45. rejects(fn()=>$save('leaf',$ids['disabled']),'disabled parent rejected');
  46. rejects(fn()=>$save('leaf',99999999),'missing parent rejected');
  47. check($dashboard->detail($ids['leaf'])['agent']['level']===3,'invalid reparent left hierarchy intact');
  48. $save('leaf',$ids['root']); check($dashboard->detail($ids['leaf'])['agent']['level']===2,'reparent recalculates level');
  49. $save('leaf',$ids['child']);
  50. $enterprises=[];
  51. 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'];
  52. $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]];
  53. 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]);
  54. $data=$dashboard->dashboard(['keyword'=>$tag,'limit'=>100]); $rows=array_column($data['list']['data'],null,'id');
  55. check($rows[$ids['root']]['sales_cents']===6000 && $rows[$ids['root']]['month_cents']===5000,'month boundary and lifetime sales');
  56. check($rows[$ids['child']]['month_cents']===7000 && $rows[$ids['other']]['month_cents']===0,'future sales excluded');
  57. check($rows[$ids['root']]['enterprise_count']===1,'orders do not multiply enterprise count');
  58. check($data['summary']['sales_cents']-$before['summary']['sales_cents']===13000,'global sales delta');
  59. check($data['summary']['active_members']-$before['summary']['active_members']===1,'active member has real monthly sales');
  60. $rankIds=array_column($data['ranking'],'id');
  61. check(array_search($ids['child'],$rankIds,true)<array_search($ids['root'],$rankIds,true),'monthly sales ranking');
  62. check($data['summary']['commission_cents']===$before['summary']['commission_cents'] && $rows[$ids['root']]['commission_cents']===0 && $data['commission']['configured']===true,'only recorded commissions are shown');
  63. $detail=$dashboard->detail($ids['root']);
  64. check(count($detail['children'])===1 && count($detail['enterprises'])===1,'detail direct team and enterprises');
  65. $password='Test-'.bin2hex(random_bytes(12));
  66. $input=['id'=>$ids['root'],'username'=>$tag.'-user','password'=>$password];
  67. rejects(fn()=>$management->openAccount($input,101),'non-superuser cannot open account');
  68. rejects(fn()=>$management->openAccount(array_replace($input,['password'=>'123']),1),'short password rejected');
  69. $account=$management->openAccount($input,1); $uid=$account['user_id'];
  70. $user=WaterOperator::query()->find($uid);
  71. check(password_verify($password,$user->getRawOriginal('password')),'password hashed');
  72. check(!array_key_exists('password',$user->toArray()),'model password hidden');
  73. check(WaterOperatorRole::query()->where('user_id',$uid)->count()===0 && (int)$user->is_super===0 && $user->dept_id===null,'no automatic roles or scope');
  74. check($dashboard->detail($ids['root'])['agent']['authorization']==='pending','account without roles is pending');
  75. rejects(fn()=>$management->openAccount(array_replace($input,['username'=>$tag.'-new']),1),'agent account duplicate rejected');
  76. rejects(fn()=>$management->openAccount(array_replace($input,['id'=>$ids['child']]),1),'duplicate username rejected');
  77. // A valid water permission is required before an active linked account can be shown authorized.
  78. $role=tableModel('sa_system_role')->insertGetId(['name'=>$tag,'code'=>$tag,'status'=>1,'data_scope'=>4]);
  79. $menu=tableModel('sa_system_menu')->where('slug','water:agents:read')->whereNull('delete_time')->value('id');
  80. check((bool)$menu,'existing water permission');
  81. tableModel('sa_system_role_menu')->insert(['role_id'=>$role,'menu_id'=>$menu]);
  82. WaterOperatorRole::query()->insert(['user_id'=>$uid,'role_id'=>$role]);
  83. check($dashboard->detail($ids['root'])['agent']['authorization']==='authorized','live permission authorizes display');
  84. $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor();
  85. $reflection->getProperty('adminId')->setValue($controller,$uid);
  86. $request=new \support\Request("GET /core/water/agents/dashboard HTTP/1.1\r\nHost: localhost\r\n\r\n");
  87. check(json_decode($controller->agentDashboard($request)->rawBody(),true)['code']===200,'authorized read');
  88. WaterAgent::query()->where('id',$ids['root'])->update(['status'=>2]);
  89. rejects(fn()=>$controller->agentDashboard($request),'disabled agent blocked despite role');
  90. check($dashboard->detail($ids['root'])['agent']['authorization']==='disabled','disabled agent display');
  91. WaterAgent::query()->where('id',$ids['root'])->update(['status'=>1]);
  92. tableModel('sa_system_role')->where('id',$role)->update(['status'=>0]);
  93. rejects(fn()=>$controller->agentDashboard($request),'disabled role immediately blocked');
  94. check($dashboard->detail($ids['root'])['agent']['authorization']==='pending','disabled role removes display authorization');
  95. WaterOperator::query()->where('id',$uid)->update(['status'=>2]);
  96. check($dashboard->detail($ids['root'])['agent']['authorization']==='disabled','disabled linked account');
  97. $reflection->getProperty('adminId')->setValue($controller,99999999);
  98. rejects(fn()=>$controller->agentDashboard($request),'dashboard unauthorized');
  99. rejects(fn()=>$controller->agentDetail($request),'detail unauthorized');
  100. $body=json_encode($input);
  101. $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);
  102. $response=$controller->agentOpenAccount($post)->rawBody();
  103. check(!str_contains($response,$password) && (json_decode($response,true)['code']??0)!==200,'opening error does not echo credentials');
  104. check(!str_contains(serialize(WaterLedger::query()->where('reference','agent:'.$ids['root'])->get()->toArray()),$password),'audit excludes password');
  105. foreach ([['keyword'=>[]],['keyword'=>str_repeat('字',121)],['level'=>'9'],['authorization'=>'bad']] as $input) rejects(fn()=>$dashboard->dashboard($input),'invalid filters');
  106. rejects(fn()=>$dashboard->detail(99999999),'missing detail');
  107. $roleless=['name'=>$tag.'-new','contact'=>'TEST','phone'=>'TEST','region'=>'TEST','parent_id'=>0,'username'=>$tag.'-new-user','password'=>$password];
  108. $created=$management->openAccount($roleless,1);
  109. check((int)WaterAgent::query()->find($created['id'])->user_id===$created['user_id'],'new archive and account created atomically');
  110. $agentCount=WaterAgent::query()->count();
  111. rejects(fn()=>$management->openAccount(array_replace($roleless,['username'=>$tag.'-rollback','phone'=>str_repeat('x',21)]),1),'account validation rollback');
  112. check(WaterAgent::query()->count()===$agentCount && !WaterOperator::query()->where('username',$tag.'-rollback')->exists(),'no orphan archive on opening failure');
  113. } finally { $db->rollBack(); }
  114. check(!WaterAgent::query()->where('name','like',$tag.'%')->exists() && !WaterOperator::query()->where('username','like',$tag.'%')->exists(),'all fixtures rolled back');
  115. if (in_array('--http',$argv,true)) {
  116. $token=\Tinywan\Jwt\JwtToken::generateToken(['id'=>1,'username'=>'admin','plat'=>'saiadmin','type'=>'web','client'=>$tag,'access_exp'=>120,'refresh_exp'=>120])['access_token'];
  117. $id=WaterAgent::query()->orderBy('id')->value('id');
  118. foreach (['dashboard?limit=1','detail?id='.$id] as $path) {
  119. $ch=curl_init('http://127.0.0.1:8787/core/water/agents/'.$path);
  120. curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>15,CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$token]]);
  121. $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); $body=json_decode((string)$raw,true);
  122. check($status===200 && ($body['code']??0)===200,'HTTP '.$path);
  123. check(!str_contains((string)$raw,'"password"'),'HTTP excludes password');
  124. }
  125. }
  126. echo "PASS: $checks agent checks; all fixtures rolled back.\n";