['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes,dirname(__DIR__)); if(!is_resource($process))throw new RuntimeException('Could not start PHP route fixture.'); fclose($pipes[0]);$body=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$status=proc_close($process); putenv('EXOPLASIM_API_TEST_CHILD');putenv('EXOPLASIM_API_TEST_QUERY'); if($status!==0)throw new RuntimeException('PHP route fixture failed: '.$error); return $body; } function reply_data(string $body): array { $reply=json_decode($body,true); check(is_array($reply),'Expected a JSON response.'); return $reply; } function create_job(PDO $db,string $id,string $kind,string $spec,string $now): void { $s=$db->prepare("INSERT INTO jobs(id,owner,name,spec,kind,status,total_cycles,created_at,updated_at) VALUES(?,?,?,?,?,'completed',1,?,?)"); $s->execute([$id,'local',$kind.' test',$spec,$kind,$now,$now]); } function write_json(string $path,array $value): void { file_put_contents($path,json_encode($value,JSON_THROW_ON_ERROR)); } $root=dirname(__DIR__);$tmp=sys_get_temp_dir().'/exoplasim-api-'.bin2hex(random_bytes(5));mkdir($tmp,0700,true); foreach(['catalog','jobs','runtime','data'] as $dir)mkdir($tmp.'/'.$dir,0750,true); $config=json_decode(file_get_contents($root.'/config/web.json'),true,512,JSON_THROW_ON_ERROR); $config['storage']['root']=$tmp;$config['storage']['catalog']='catalog/parameters.json';$config['max_result_bytes']=256;$config['max_field_memory_bytes']=64; file_put_contents($tmp.'/config.json',json_encode($config,JSON_THROW_ON_ERROR));putenv('EXOPLASIM_CONFIG='.$tmp.'/config.json'); try { $command=escapeshellarg($root.'/.venv/bin/python').' '.escapeshellarg($root.'/scripts/web/parameters.py').' --config '.escapeshellarg($tmp.'/config.json').' --output '.escapeshellarg($tmp.'/catalog/parameters.json').' 2>&1'; exec($command,$output,$status);check($status===0,'Could not generate the test catalog: '.implode("\n",$output)); $db=LabDb::connection();$now=gmdate('c');$modelId=str_repeat('a',32);$modelDataset=str_repeat('c',32);$planetId=str_repeat('b',32);$planetDataset=str_repeat('d',32);$token=str_repeat('e',32); $spec=json_encode(['model'=>[],'run'=>['cycles'=>1],'parameters'=>(object)[],'maps'=>['height'=>$token]],JSON_THROW_ON_ERROR); create_job($db,$modelId,'model',$spec,$now);create_job($db,$planetId,'planet',$spec,$now); $modelRoot=$tmp.'/jobs/'.$modelId;$modelArtifacts=$modelRoot.'/artifacts';$modelData=$modelArtifacts.'/datasets/'.$modelDataset; mkdir($modelData,0750,true);mkdir($modelRoot.'/runtime',0750,true);mkdir($modelRoot.'/inputs/maps',0750,true);mkdir($modelRoot.'/inputs/native',0750,true); file_put_contents($modelRoot.'/inputs/maps/height.f32','fixture input map');file_put_contents($modelRoot.'/inputs/native/height.sra','fixture native input'); file_put_contents($modelRoot.'/worker.log','fixture worker log');file_put_contents($modelRoot.'/runtime/GUI.cfg','fixture model config');file_put_contents($modelRoot.'/runtime/MOST.00000.npz','fixture raw model NPZ');file_put_contents($modelRoot.'/runtime/MOST.00000_metadata.npz','fixture raw model metadata');file_put_contents($modelRoot.'/runtime/plasim_restart','fixture restart');file_put_contents($modelRoot.'/runtime/MOST_REST.00000','fixture cycle restart'); $index=['id'=>$modelDataset,'lat'=>[-30,30],'lon'=>[0,120,240],'time'=>[0,1],'time_unit'=>'years','levels'=>[0],'fields'=>[['name'=>'temperature','label'=>'Temperature','unit'=>'K','shape'=>[2,1,2,3]]]]; write_json($modelData.'/index.json',$index);write_json($modelData.'/temperature.json',['mean'=>[[280],[281]],'min'=>[[270],[271]],'max'=>[[290],[291]]]);$fieldBytes=pack('g*',1,2,3,4,5,6,7,8,9,10,11,12);file_put_contents($modelData.'/temperature.f32',$fieldBytes); write_json($modelArtifacts.'/index.json',['datasets'=>[['id'=>$modelDataset,'label'=>'Cycle 1']],'latest'=>$modelDataset]); $planetRoot=$tmp.'/jobs/'.$planetId;$planetArtifacts=$planetRoot.'/artifacts';$planetData=$planetArtifacts.'/datasets/'.$planetDataset; mkdir($planetData,0750,true);mkdir($planetArtifacts.'/planet-work',0750,true);mkdir($planetRoot.'/runtime',0750,true); file_put_contents($planetRoot.'/worker.log','planet worker log');file_put_contents($planetArtifacts.'/planet-work/surface-'.$token.'.npz','fixture terrain NPZ');file_put_contents($planetArtifacts.'/planet-work/surface-'.$token.'_metadata.npz','fixture terrain metadata');file_put_contents($planetArtifacts.'/planet.json','fixture terrain recipe');file_put_contents($planetArtifacts.'/planet-work/N064_surf_0129.sra','fixture topography SRA');file_put_contents($planetArtifacts.'/planet-work/N064_surf_0172.sra',str_repeat('s',257)); $planetIndex=['id'=>$planetDataset,'lat'=>[-30,30],'lon'=>[0,120,240],'time'=>[0],'time_unit'=>'','levels'=>[0],'fields'=>[['name'=>'elevation','label'=>'Elevation','unit'=>'m','shape'=>[1,1,2,3]]]]; write_json($planetData.'/index.json',$planetIndex);write_json($planetArtifacts.'/index.json',['datasets'=>[['id'=>$planetDataset,'label'=>'Generated planet surface']],'latest'=>$planetDataset]); $schema=reply_data(request_api(['api'=>'schema']));check($schema['ok']===true&&isset($schema['data']['fields']),'The ordinary api=schema query must resolve.'); $unknown=reply_data(request_api(['api'=>'not_found']));check(empty($unknown['ok'])&&isset($unknown['errors']['route']),'Unknown query operations must return a structured error.'); $modelResults=reply_data(request_api(['api'=>'results','id'=>$modelId]));check($modelResults['ok']===true,'The query-param results route must resolve.');$modelLinks=array_column($modelResults['data']['downloads'],'id'); foreach(['log','model-config','raw:'.$modelDataset,'raw-metadata:'.$modelDataset,'dataset-index:'.$modelDataset,'restart:plasim_restart','restart:MOST_REST.00000','field:'.$modelDataset.':temperature'] as $link)check(in_array($link,$modelLinks,true),'Results must list '.$link.'.'); foreach(['input-map:height','native-input:height'] as $link)check(in_array($link,$modelLinks,true),'Results must list '.$link.'.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'input-map:height'])==='fixture input map','Captured map download must work.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'native-input:height'])==='fixture native input','Retained native input download must work.'); check(empty(reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'native-input:unknown']))['ok']),'Unknown native inputs must fail.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'log'])==='fixture worker log','Log download must work without dataset or field parameters.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'model-config'])==='fixture model config','Model config download must work.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw:'.$modelDataset])==='fixture raw model NPZ','Raw model output download must work.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw-metadata:'.$modelDataset])==='fixture raw model metadata','Raw model metadata download must work.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'dataset-index:'.$modelDataset])===file_get_contents($modelData.'/index.json'),'Dataset index download must work.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'restart:plasim_restart'])==='fixture restart','Model restart download must work.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'restart:MOST_REST.00000'])==='fixture cycle restart','Cycle restart download must work.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'field:'.$modelDataset.':temperature'])===$fieldBytes,'Field download must return the allowlisted binary field.'); $missing=reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'field:'.$modelDataset.':missing']));check(empty($missing['ok']),'Missing field download must fail.'); $arbitrary=reply_data(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'../../etc/passwd']));check(empty($arbitrary['ok']),'Arbitrary paths must not be downloadable.'); $frame=reply_data(request_api(['api'=>'frame','id'=>$modelId,'dataset'=>$modelDataset,'field'=>'temperature','time'=>2,'level'=>0]));check(empty($frame['ok'])&&isset($frame['errors']['selection']),'Out-of-range frames must fail.'); check(request_api(['api'=>'frame','id'=>$modelId,'dataset'=>$modelDataset,'field'=>'temperature','time'=>0,'level'=>0])===substr($fieldBytes,0,24),'Valid frames must return one binary frame.'); $secondDataset=str_repeat('f',32);$secondData=$modelArtifacts.'/datasets/'.$secondDataset;mkdir($secondData,0750,true);$secondIndex=$index;$secondIndex['id']=$secondDataset; write_json($secondData.'/index.json',$secondIndex);file_put_contents($secondData.'/temperature.f32',$fieldBytes);file_put_contents($modelRoot.'/runtime/MOST.00001.npz','second cycle NPZ'); write_json($modelArtifacts.'/index.json',['datasets'=>[['id'=>$modelDataset,'label'=>'Cycle 1'],['id'=>$secondDataset,'label'=>'Cycle 2']],'latest'=>$secondDataset]); $secondResults=reply_data(request_api(['api'=>'results','id'=>$modelId]));$secondLinks=array_column($secondResults['data']['downloads'],'id'); check(in_array('raw:'.$secondDataset,$secondLinks,true),'Latest-cycle results must list their own raw output.'); check(!in_array('raw:'.$modelDataset,$secondLinks,true)&&!in_array('field:'.$modelDataset.':temperature',$secondLinks,true),'Download listings must not scan and include unrelated cycles.'); check(request_api(['api'=>'download','id'=>$modelId,'artifact'=>'raw:'.$modelDataset])==='fixture raw model NPZ','Older-cycle downloads must still resolve directly.'); $planetResults=reply_data(request_api(['api'=>'results','id'=>$planetId]));check($planetResults['ok']===true,'Planet results route must resolve.');$planetLinks=array_column($planetResults['data']['downloads'],'id'); foreach(['terrain-recipe','dataset-index:'.$planetDataset,'sra:N064_surf_0129.sra','raw:'.$planetDataset.':surface-'.$token.'.npz','raw-metadata:'.$planetDataset.':surface-'.$token] as $link)check(in_array($link,$planetLinks,true),'Planet results must list '.$link.'.'); check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'sra:N064_surf_0129.sra'])==='fixture topography SRA','Native SRA download must work.'); check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'raw:'.$planetDataset.':surface-'.$token.'.npz'])==='fixture terrain NPZ','Raw terrain preview download must work.'); check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'raw-metadata:'.$planetDataset.':surface-'.$token])==='fixture terrain metadata','Raw terrain metadata download must work.'); check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'dataset-index:'.$planetDataset])===file_get_contents($planetData.'/index.json'),'Planet dataset index download must work.'); check(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'terrain-recipe'])==='fixture terrain recipe','Terrain recipe download must work.'); $oversized=reply_data(request_api(['api'=>'download','id'=>$planetId,'artifact'=>'sra:N064_surf_0172.sra']));check(empty($oversized['ok'])&&str_contains($oversized['errors']['artifact']??'','size limit'),'Oversized artifacts must be rejected.'); echo "PHP query-route artifact tests passed\n"; } finally { putenv('EXOPLASIM_CONFIG');putenv('EXOPLASIM_API_TEST_CHILD');putenv('EXOPLASIM_API_TEST_QUERY'); $it=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST); foreach($it as $file){$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());} rmdir($tmp); }