From f7d1792e232b9d38a156b5305faf7a655fb722f1 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Wed, 28 Oct 2026 05:06:11 +0300 Subject: [PATCH] =?UTF-8?q?api#347:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20part=20=D0=B2=20=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BD=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B0=D1=85=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/Config.php | 1 + admin/src/PercentMigration.php | 2 +- admin/src/PercentWeights.php | 2 +- admin/src/UtmPresets.php | 2 +- admin/tests/link-builder.php | 2 +- admin/tests/part.php | 8 ++++---- admin/tests/percent-migration.php | 3 +++ admin/tests/percent-weights.php | 2 +- admin/tests/preset-ui-helper.cjs | 2 +- admin/tests/utm-presets.php | 6 +++--- 10 files changed, 17 insertions(+), 13 deletions(-) diff --git a/admin/src/Config.php b/admin/src/Config.php index fe0b566..4b6aed6 100644 --- a/admin/src/Config.php +++ b/admin/src/Config.php @@ -74,6 +74,7 @@ final class Config $number=static function($value) use($maps,$canonical): string { if (!is_int($value) && !is_string($value)) throw new Problem('Вариант UTM должен быть номером.'); $value=(string)$value; + if ($canonical==='part' && !in_array($value,['1','2'],true)) throw new Problem('Вариант part должен быть 1 или 2.'); if (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; } if (!preg_match('/^[1-9]$/D',$value)) throw new Problem('Вариант UTM должен быть 1–9.'); return $value; diff --git a/admin/src/PercentMigration.php b/admin/src/PercentMigration.php index e5f7056..42d1113 100644 --- a/admin/src/PercentMigration.php +++ b/admin/src/PercentMigration.php @@ -5,7 +5,7 @@ namespace PrelandAdmin; /** Явная миграция #28: заменяет исключительно числовые токены известных весов. */ final class PercentMigration { - private const KEYS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus']; + private const KEYS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part']; public static function patch(string $raw): string { diff --git a/admin/src/PercentWeights.php b/admin/src/PercentWeights.php index 400465f..41cfc05 100644 --- a/admin/src/PercentWeights.php +++ b/admin/src/PercentWeights.php @@ -5,7 +5,7 @@ namespace PrelandAdmin; /** Контракт хранимых процентов. Миграция вызывается только явно, отдельно от чтения и записи. */ final class PercentWeights { - public const GROUPS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus']; + public const GROUPS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part']; private static function groups(\stdClass $config): array { diff --git a/admin/src/UtmPresets.php b/admin/src/UtmPresets.php index 03e8c73..e8831c1 100644 --- a/admin/src/UtmPresets.php +++ b/admin/src/UtmPresets.php @@ -5,7 +5,7 @@ namespace PrelandAdmin; /** Независимые снимки UTM: без доменов, подключений, заметок и происхождения. */ final class UtmPresets { - private const GROUPS=['indpic','theme','degr','reg','banch','tp','cons','focus']; + private const GROUPS=['indpic','theme','degr','reg','banch','tp','cons','focus','part']; public function __construct(private Store $store) {} public function all(): array { return array_values(array_map(static fn($p)=>['id'=>$p['id'],'name'=>$p['name']],$this->store->read('utm-presets.json'))); diff --git a/admin/tests/link-builder.php b/admin/tests/link-builder.php index 1d4c003..d59f850 100644 --- a/admin/tests/link-builder.php +++ b/admin/tests/link-builder.php @@ -53,7 +53,7 @@ $targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed $renamed = $builder->get('first'); linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin'); linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise'); -foreach (array_keys($selection) as $key) { +foreach (array_diff(array_keys($selection),['part']) as $key) { $manualSelection=array_fill_keys(array_keys($selection),'rnd');$manualSelection[$key]='9'; $manual=$builder->save('second',$manualSelection);parse_str(parse_url($manual['url'],PHP_URL_QUERY),$manualParameters); linkCheck($builder->get('second')===$manual&&$manual['selection'][$key]==='9'&&$manualParameters[$key][4]==='9','manual digit 9 encodes and persists for '.$key); diff --git a/admin/tests/part.php b/admin/tests/part.php index f791393..5593f93 100644 --- a/admin/tests/part.php +++ b/admin/tests/part.php @@ -5,17 +5,17 @@ require dirname(__DIR__).'/bootstrap.php'; use PrelandAdmin\{Config,Copy,LinkBuilder,Notes,Store,Problem}; function partCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS $message\n";} $valid=Config::decode('{"host":"keep.example","metadata":{"keep":true},"utm":{"part":{"default":"2","weights":{"1":0,"2":100}},"unknown":{"keep":true}}}'); -Config::validate($valid,static fn()=>true); -foreach (['{"default":"3","weights":{}}','{"default":"1","weights":{"9":1}}','{"default":"1","weights":{"2":-1}}'] as $group) { +Config::validate($valid); +foreach (['{"default":"3","weights":{}}','{"default":"1","weights":{"9":1}}','{"default":"1","weights":{"2":-1}}','{"default":"1","weights":{"1":1}}'] as $group) { $invalid=Config::decode('{"utm":{"part":'.$group.'}}');$rejected=false; - try {Config::validate($invalid,static fn()=>true);}catch(Problem){$rejected=true;} + try {Config::validate($invalid);}catch(Problem){$rejected=true;} partCheck($rejected,'invalid part enum/weights rejected'); } $project=new ReflectionMethod(Copy::class,'project');$changes=$project->invoke(null,$valid); $target=Config::decode('{"host":"before.example","notes":"keep","utm":{"part":{"default":"1","weights":{"1":1},"metadata":"keep"},"unknown":{"keep":true}}}'); $patched=Config::patch($target,$changes); partCheck($patched->utm->part->default==='2'&&$patched->utm->part->weights->{'2'}===100,'copy includes part default and weights'); -partCheck($patched->notes==='keep'&&$patched->utm->part->metadata==='keep'&&$patched->utm->unknown->keep===true,'copy preserves metadata and unknown groups'); +partCheck($patched->host==='before.example'&&$patched->notes==='keep'&&$patched->utm->part->metadata==='keep'&&$patched->utm->unknown->keep===true,'copy preserves metadata and unknown groups'); $root=sys_get_temp_dir().'/part347-'.bin2hex(random_bytes(6));$store=new Store($root); $notes=new Notes($store);$old=$notes->all();unset($old['part']);$store->write('utm-notes.json',$old); $loaded=$notes->all();partCheck(isset($loaded['part'])&&array_diff_key($loaded,['part'=>true])===$old,'old notes gain empty part without losing notes/versions'); diff --git a/admin/tests/percent-migration.php b/admin/tests/percent-migration.php index a3df005..65fa859 100644 --- a/admin/tests/percent-migration.php +++ b/admin/tests/percent-migration.php @@ -29,3 +29,6 @@ foreach([ '{"utm":null}', ] as $i=>$source)migrationReject($source,'unsafe/ambiguous input rejected '.$i); echo "Raw percent migration complete\n"; + +$partRaw='{"metadata":"keep","utm":{"part":{"default":"2","weights":{"1":1,"2":3}}}}'; +migrationCheck(PercentMigration::patch($partRaw)===str_replace('{"1":1,"2":3}','{"1":25,"2":75}',$partRaw),'part relative weights normalize only explicit numeric spans'); diff --git a/admin/tests/percent-weights.php b/admin/tests/percent-weights.php index d272c2b..5d0f2a1 100644 --- a/admin/tests/percent-weights.php +++ b/admin/tests/percent-weights.php @@ -5,7 +5,7 @@ use PrelandAdmin\{Config,PercentWeights,Problem,Store,Transport,Jobs,Copy}; function percentCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";} function percentReject(callable $call,string $message): void {try{$call();}catch(Problem $e){percentCheck($e->status===422,$message);return;}throw new RuntimeException('Accepted: '.$message);} function percentConfig(mixed $weights,string $key='theme'): stdClass {return (object)['utm'=>(object)[$key=>(object)['default'=>1,'weights'=>$weights,'extra'=>(object)['keep'=>true]]],'custom'=>(object)['weights'=>[1,2]]];} -foreach(['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus'] as $key)foreach([(object)['1'=>100],(object)['1'=>33.33,'2'=>66.67],(object)['1'=>0,'2'=>0],new stdClass()] as $weights)PercentWeights::validate(percentConfig($weights,$key)); +foreach(['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'] as $key)foreach([(object)['1'=>100],(object)['1'=>33.33,'2'=>66.67],(object)['1'=>0,'2'=>0],new stdClass()] as $weights)PercentWeights::validate(percentConfig($weights,$key)); percentCheck(true,'all canonical groups, aliases, two decimals and zero groups accepted'); $precisionCases=['{"1":33.33000000000001,"2":66.67}','{"1":99.9999999999999}','{"1":0.010000000000000002,"2":99.99}'];$acceptedPrecision=[]; foreach ($precisionCases as $json) {try {PercentWeights::validate(percentConfig(json_decode($json,false,16,JSON_THROW_ON_ERROR)));$acceptedPrecision[]=$json;echo "FAIL excessive precision accepted {$json}\n";}catch(Problem $e){percentCheck($e->status===422,'strict stored precision rejects '.$json);}} diff --git a/admin/tests/preset-ui-helper.cjs b/admin/tests/preset-ui-helper.cjs index 4bb2dea..5767616 100644 --- a/admin/tests/preset-ui-helper.cjs +++ b/admin/tests/preset-ui-helper.cjs @@ -2,7 +2,7 @@ module.exports=async function choosePreset(page,id){ const state=await page.request.get('/api/state').then(r=>r.json()); const read=await page.request.get('/api/config?target='+id).then(r=>r.json()),utm={}; - for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const aliases=marker==='reg'?['reg','registration']:marker==='banch'?['banch','chob','bchoice']:[marker];const key=aliases.find(k=>Object.hasOwn(read.config.utm||{},k));if(key)utm[marker]={default:read.config.utm[key].default,weights:read.config.utm[key].weights};} + for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const aliases=marker==='reg'?['reg','registration']:marker==='banch'?['banch','chob','bchoice']:[marker];const key=aliases.find(k=>Object.hasOwn(read.config.utm||{},k));if(key)utm[marker]={default:read.config.utm[key].default,weights:read.config.utm[key].weights};} const response=await page.request.post('/api/utm-presets',{headers:{'X-CSRF-Token':state.csrf},data:{name:'UI-'+Date.now()+'-'+Math.random(),utm}}),saved=await response.json(); if(!saved.ok)throw Error(saved.error);await page.locator('#preset-reload').click();await page.waitForFunction(id=>[...document.querySelector('#copy-source').options].some(o=>o.value===id),saved.preset.id);await page.locator('#copy-source').selectOption(saved.preset.id);return saved.preset.id; }; diff --git a/admin/tests/utm-presets.php b/admin/tests/utm-presets.php index ea86fae..af5e7f0 100644 --- a/admin/tests/utm-presets.php +++ b/admin/tests/utm-presets.php @@ -5,14 +5,14 @@ use PrelandAdmin\{UtmPresets,Store,Copy,Config,Jobs,Transport,Problem}; function checkPreset(bool $ok,string $label):void{if(!$ok)throw new RuntimeException($label);echo "PASS $label\n";} function rejectsPreset(callable $f,string $label):void{try{$f();}catch(Problem){checkPreset(true,$label);return;}throw new RuntimeException($label);} $root=sys_get_temp_dir().'/presets-'.bin2hex(random_bytes(6));mkdir($root,0700);$store=new Store($root.'/state');$presets=new UtmPresets($store); -$utm=Config::decode('{"theme":{"default":"3","weights":{"1":33.33,"3":66.67}},"cons":{"default":2,"weights":{"1":0,"2":0}}}'); +$utm=Config::decode('{"theme":{"default":"3","weights":{"1":33.33,"3":66.67}},"cons":{"default":2,"weights":{"1":0,"2":0}},"part":{"default":"2","weights":{"1":0,"2":100}}}'); $saved=$presets->create(['name'=>'Осень','utm'=>$utm]); checkPreset(array_keys($saved)===['id','name','utm'],'preset has no source domain or target binding'); $utm->theme->weights->{'1'}=100;checkPreset($presets->get($saved['id'])['utm']->theme->weights->{'1'}===33.33,'draft is an immutable independent snapshot'); checkPreset((new UtmPresets(new Store($store->dir)))->get($saved['id'])['name']==='Осень','preset survives store reload'); foreach([['name'=>'','utm'=>$saved['utm']],['name'=>' ','utm'=>$saved['utm']],['name'=>'осень','utm'=>$saved['utm']],['name'=>'x','utm'=>$saved['utm'],'domain'=>'a.invalid'],['name'=>'x','utm'=>(object)[]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>80]]]],['name'=>'x','utm'=>(object)['theme'=>(object)['default'=>1,'weights'=>(object)['1'=>100],'note'=>'secret']]],['name'=>'x','utm'=>(object)['unknown'=>(object)[]]]] as $bad)rejectsPreset(fn()=>$presets->create($bad),'strict independent preset validation'); $paths=[];foreach(['a','b'] as $id){$paths[$id]=$root.'/'.$id;mkdir($paths[$id]);}putenv('ADMIN_LOCAL_ROOTS='.implode(',',$paths)); -$raw='{"host":"keep.invalid","utm":{"theme":{"default":"dark","weights":{"dark":100},"note":"keep"},"reg":{"default":2,"weights":{"2":100}},"custom":{"a":1}},"requisites":["keep"],"unknown":{}}'; +$raw='{"host":"keep.invalid","utm":{"theme":{"default":"dark","weights":{"dark":100},"note":"keep"},"reg":{"default":2,"weights":{"2":100}},"part":{"default":"1","weights":{"1":100},"metadata":{"keep":true}},"custom":{"a":1}},"requisites":["keep"],"unknown":{}}'; foreach($paths as $id=>$path){file_put_contents($path.'/config.json',$raw);$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$path,'enabled'=>true]);$store->verifyTarget($id);} $store->write('utm-notes.json',['theme'=>['text'=>'keep note']]);$notes=file_get_contents($store->path('utm-notes.json')); $read=fn($id)=>(new Transport($store,$store->target($id)))->readConfig();$selection=fn($id)=>(object)['id'=>$id,'version'=>$read($id)['version']]; @@ -20,7 +20,7 @@ $copy=new Copy($store);$job=$copy->create(['preset'=>$saved['id'],'targets'=>[$s $presets->delete($saved['id']);rejectsPreset(fn()=>$presets->get($saved['id']),'deleted preset unavailable'); file_put_contents($paths['b'].'/config.json',str_replace('keep.invalid','foreign.invalid',$raw));$jobs=new Jobs($store);$jobs->run($job['id']);$done=$jobs->get($job['id']); checkPreset($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='conflict','queued snapshot survives deletion and isolates CAS conflict'); -$a=$read('a')['config'];checkPreset($a->host==='keep.invalid'&&$a->utm->theme->note==='keep'&&$a->utm->reg->default===2&&$a->utm->custom->a===1&&$a->unknown instanceof stdClass,'host, absent groups, metadata and unknown types unchanged'); +$a=$read('a')['config'];checkPreset($a->utm->part->default==='2'&&$a->utm->part->weights->{'1'}===0&&$a->utm->part->weights->{'2'}===100&&$a->utm->part->metadata->keep===true,'part preserved through independent preset queue, CAS, metadata and exact backup');checkPreset($a->host==='keep.invalid'&&$a->utm->theme->note==='keep'&&$a->utm->reg->default===2&&$a->utm->custom->a===1&&$a->unknown instanceof stdClass,'host, absent groups, metadata and unknown types unchanged'); checkPreset($a->utm->theme->default==='3'&&$a->utm->theme->weights->dark===33.33&&$a->utm->theme->weights->{'3'}===66.67&&$a->utm->cons->weights->{'2'}===0,'manual variants, semantic targets and all-zero percentages copied'); checkPreset($store->backupContent('a',$store->backups('a')[0]['id'])===$raw&&$store->backups('b')===[],'exact backup only for successful recipient'); checkPreset(file_get_contents($store->path('utm-notes.json'))===$notes,'notes stay byte identical');