load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use plugin\saiadmin\app\model\water\{WaterContent,WaterPackage,WaterSku,WaterLedger}; use plugin\saiadmin\app\service\{WaterBusiness,WaterContentDashboard}; use plugin\saiadmin\app\service\content\ContentPolicy; 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 request(string $method,string $path,array $input=[]): \support\Request { $body=$method==='POST'?json_encode($input):''; if ($method==='GET' && $input) $path.='?'.http_build_query($input); return new \support\Request($method.' '.$path." HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n".$body); } $now=new DateTimeImmutable('2026-09-17 12:00:00',new DateTimeZone('Asia/Shanghai')); $service=new WaterContentDashboard($now); $business=new WaterBusiness(); $tag='CONTENT-'.bin2hex(random_bytes(4)); $db=(new WaterContent())->getConnection(); $before=WaterContent::query()->orderBy('id')->get()->toArray(); $beforeLedger=WaterLedger::query()->count(); $db->beginTransaction(); try { $baseline=$service->dashboard([]); $rows=[ 'all'=>['audience'=>'all','sort'=>60], 'new'=>['audience'=>'new','region'=>'上海','sort'=>50,'starts_at'=>'2026-09-17 12:00:00'], 'purchased'=>['audience'=>'purchased','region'=>'上海','sort'=>50], 'admin'=>['audience'=>'enterprise_admin','region'=>'上海','sort'=>40], 'scheduled'=>['audience'=>'all','starts_at'=>'2026-09-17 12:00:01','sort'=>30], 'expired'=>['audience'=>'all','ends_at'=>'2026-09-17 11:59:59','sort'=>20], 'disabled'=>['audience'=>'all','status'=>2,'sort'=>70], 'end'=>['audience'=>'all','ends_at'=>'2026-09-17 12:00:00','sort'=>55], 'guangzhou'=>['audience'=>'new','region'=>'广州','sort'=>45], ]; $ids=[]; foreach ($rows as $key=>$input) $ids[$key]=$business->save('content',['title'=>$tag.'-'.$key]+$input,1)['id']; $first=$service->dashboard(['keyword'=>' '.$tag.' ','limit'=>4]); $second=$service->dashboard(['keyword'=>$tag,'limit'=>4,'page'=>2]); $third=$service->dashboard(['keyword'=>$tag,'limit'=>4,'page'=>3]); check($first['list']['total']===9 && count($first['list']['data'])===4 && count($third['list']['data'])===1,'4/4/1 pages'); $expected=[$ids['disabled'],$ids['all'],$ids['end'],$ids['purchased'],$ids['new'],$ids['guangzhou'],$ids['admin'],$ids['scheduled'],$ids['expired']]; $combined=array_merge($first['list']['data'],$second['list']['data'],$third['list']['data']); check(array_column($combined,'id')===$expected,'sort descending then ID descending'); check($first['summary']===$second['summary'],'summary independent of pagination'); check($first['summary']['total']-$baseline['summary']['total']===9,'global total'); foreach (['active'=>6,'scheduled'=>1,'expired'=>1,'disabled'=>1] as $status=>$count) { check($first['summary'][$status]-$baseline['summary'][$status]===$count,'global status '.$status); $filtered=$service->dashboard(['keyword'=>$tag,'status'=>$status]); check($filtered['list']['total']===$count && count(array_filter($filtered['list']['data'],fn($r)=>$r['delivery_status']===$status))===$count,'status filter '.$status); check($filtered['summary']===$first['summary'],'global count independent of '.$status.' filter'); } check($service->dashboard(['keyword'=>$tag.'-missing'])['summary']===$first['summary'],'global count independent of empty search'); check($service->dashboard(['keyword'=>$tag,'page'=>99])['list']['data']===[],'out of range page'); check($service->dashboard(['keyword'=>$tag.'-new'])['list']['total']===1,'title search'); check($service->dashboard(['keyword'=>'上海'])['list']['total']>=3,'region search'); $fixtureIds=function(array $preview) use ($ids): array { return array_values(array_intersect(array_column($preview['banners'],'id'),array_values($ids))); }; check($fixtureIds($service->preview([]))===[$ids['all'],$ids['end']],'unknown region only nationwide'); check($fixtureIds($service->preview(['region'=>' 上海 ']))===[$ids['all'],$ids['end'],$ids['new']],'new customer Shanghai and exact start'); check($fixtureIds($service->preview(['region'=>'上海','purchase_status'=>'purchased']))===[$ids['all'],$ids['end'],$ids['purchased']],'purchased excludes new and admin'); check($fixtureIds($service->preview(['region'=>'上海','purchase_status'=>'purchased','is_enterprise_admin'=>1]))===[$ids['all'],$ids['end'],$ids['purchased'],$ids['admin']],'admin plus purchased audiences'); check($fixtureIds($service->preview(['region'=>'上海','is_enterprise_admin'=>'1']))===[$ids['all'],$ids['end'],$ids['new'],$ids['admin']],'admin plus new audiences'); check($fixtureIds($service->preview(['region'=>'广州']))===[$ids['all'],$ids['end'],$ids['guangzhou']],'separate region'); check($fixtureIds($service->preview(['region'=>'上海市']))===[$ids['all'],$ids['end']],'region names use exact matching'); check($fixtureIds($service->preview(['region'=>'全国']))===[$ids['all'],$ids['end']],'national label normalized'); $after=new WaterContentDashboard($now->modify('+1 second')); check(!in_array($ids['end'],$fixtureIds($after->preview([])),true) && in_array($ids['scheduled'],$fixtureIds($after->preview([])),true),'end expiration and start activation by time'); $utc=new WaterContentDashboard(new DateTimeImmutable('2026-09-17 04:00:00',new DateTimeZone('UTC'))); check($utc->preview(['region'=>'上海'])===$service->preview(['region'=>'上海']),'Shanghai timezone normalization'); $edit=WaterContent::query()->find($ids['new'])->toArray(); $business->save('content',array_replace($edit,['status'=>2,'title'=>$tag.'-edited','region'=>'全国']),1); $persisted=WaterContent::query()->find($ids['new']); check($persisted->title===$tag.'-edited' && $persisted->region==='' && (int)$persisted->status===2,'edit persisted and national region normalized'); check(!in_array($ids['new'],$fixtureIds($service->preview(['region'=>'上海'])),true),'disable immediately removes banner'); $business->save('content',$edit,1); check(in_array($ids['new'],$fixtureIds($service->preview(['region'=>'上海'])),true),'re-enable restores delivery'); check(WaterLedger::query()->where('reference','content:'.$ids['new'])->count()===3,'create and both edits audited'); $sku=$business->save('sku',['name'=>$tag.'-sku','liters'=>18.9],1)['id']; $offSku=$business->save('sku',['name'=>$tag.'-off','liters'=>18.9,'status'=>2],1)['id']; $package=$business->save('package',['name'=>$tag.'-package','sku_id'=>$sku,'buckets'=>30,'price_cents'=>69900],1)['id']; $disabledPackage=$business->save('package',['name'=>$tag.'-disabled','sku_id'=>$sku,'buckets'=>10,'price_cents'=>19900,'status'=>2],1)['id']; $disabledSkuPackage=$business->save('package',['name'=>$tag.'-disabled-sku','sku_id'=>$offSku,'buckets'=>10,'price_cents'=>29900],1)['id']; $packages=$service->preview([])['packages']; $packageIds=array_column($packages,'id'); check(in_array($package,$packageIds,true),'preview uses persisted enabled package'); check(!in_array($disabledPackage,$packageIds,true) && !in_array($disabledSkuPackage,$packageIds,true),'disabled SKU and package excluded'); $packageRow=array_column($packages,null,'id')[$package]; check($packageRow['price_cents']===69900 && $packageRow['buckets']===30,'price and entitlement match actual package'); $invalids=[['title'=>' '],['title'=>[]],['title'=>str_repeat('字',121)],['audience'=>'agent'],['region'=>[]],['image_url'=>'javascript:alert(1)'],['target_url'=>'data:text/html,x'],['target_url'=>'https://'],['image_url'=>[]],['starts_at'=>'2026-02-30 00:00:00'],['ends_at'=>'yesterday'],['starts_at'=>[]],['starts_at'=>"2026-09-17\0"],['starts_at'=>'2026-09-18 00:00:00','ends_at'=>'2026-09-17 00:00:00'],['starts_at'=>'2026-09-17 00:00:00','ends_at'=>'2026-09-17 00:00:00'],['sort'=>-1],['sort'=>'1.5'],['status'=>'1.5']]; $contentCount=WaterContent::query()->count(); $auditCount=WaterLedger::query()->count(); foreach ($invalids as $invalid) rejects(fn()=>$business->save('content',array_replace(['title'=>$tag,'audience'=>'all'],$invalid),1),'invalid content rejected'); check(WaterContent::query()->count()===$contentCount && WaterLedger::query()->count()===$auditCount,'invalid input creates no content or audit'); foreach ([['keyword'=>[]],['status'=>'unknown'],['page'=>0],['limit'=>101]] as $invalid) rejects(fn()=>$service->dashboard($invalid),'invalid dashboard filters'); foreach ([['region'=>[]],['purchase_status'=>'agent'],['is_enterprise_admin'=>2],['is_enterprise_admin'=>[]]] as $invalid) rejects(fn()=>$service->preview($invalid),'invalid preview context'); $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,0); rejects(fn()=>$controller->contentDashboard(request('GET','/core/water/content/dashboard')),'unauthorized dashboard'); rejects(fn()=>$controller->contentPreview(request('GET','/core/water/content/preview')),'unauthorized audience preview'); rejects(fn()=>$controller->save(request('POST','/core/water/content',['title'=>$tag,'audience'=>'all']),'content'),'unauthorized save'); $reflection->getProperty('adminId')->setValue($controller,1); $response=json_decode($controller->contentDashboard(request('GET','/core/water/content/dashboard',['keyword'=>$tag]))->rawBody(),true); check(($response['code']??0)===200 && $response['data']['list']['total']===9,'dashboard controller'); check((json_decode($controller->contentPreview(request('GET','/core/water/content/preview'))->rawBody(),true)['code']??0)===200,'preview controller'); $response=json_decode($controller->save(request('POST','/core/water/content',['title'=>$tag.'-json','audience'=>'all','image_url'=>'https://example.invalid/banner.png','target_url'=>'https://example.invalid/activity']),'content')->rawBody(),true); check(($response['code']??0)===200 && WaterContent::query()->find($response['data']['id'])->target_url==='https://example.invalid/activity','JSON save path and safe URL roundtrip'); } finally { $db->rollBack(); } check(WaterContent::query()->orderBy('id')->get()->toArray()===$before && WaterLedger::query()->count()===$beforeLedger,'original content and audits restored after rollback'); check(!WaterSku::query()->where('name','like',$tag.'%')->exists() && !WaterPackage::query()->where('name','like',$tag.'%')->exists(),'package 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']; foreach (['dashboard?limit=1','preview?purchase_status=new®ion='.urlencode('上海'),'preview?purchase_status=purchased&is_enterprise_admin=1'] as $path) { $ch=curl_init('http://127.0.0.1:8787/core/water/content/'.$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); if (isset($body['data']['banners'])) foreach ($body['data']['banners'] as $row) check($row['delivery_status']==='active','HTTP only active banners'); } $result=(new WaterContentDashboard())->dashboard([]); echo 'Current content: total='.$result['summary']['total'].', active='.$result['summary']['active']."\n"; } echo "PASS: $checks content checks; all fixtures rolled back.\n";