api#347: передавать и настраивать UTM part без ссылки в Preland
This commit is contained in:
1 parent
94619432a3
commit
0d350ed14b
23 files changed
+126
-47
No files matched your search
@@ -52,7 +52,7 @@ try{
|
||||
(new PrelandAdmin\Jobs($store))->run($id);
|
||||
[$status,$body]=request('/api/jobs?id='.$id);check($body['job']['results'][0]['status']==='success'&&$body['job']['changes'][0]['path']===['host'],'HTTP persisted result and changed paths visible');
|
||||
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body['selection']===null&&$body['url']===null&&$body['saved_at']===null,'HTTP new link builder state is explicitly empty');
|
||||
$linkSelection=array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus'],'rnd');$runtimeHash=hash_file('sha256',$root.'/site/config.json');$jobCount=count(glob($store->dir.'/jobs/*.json'));
|
||||
$linkSelection=array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','part'],'rnd');$runtimeHash=hash_file('sha256',$root.'/site/config.json');$jobCount=count(glob($store->dir.'/jobs/*.json'));
|
||||
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection]);check($status===403,'HTTP link generation requires CSRF');
|
||||
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&$body['selection']===$linkSelection&&str_starts_with($body['url'],'https://test.invalid/?'),'HTTP explicit link generation returns saved independent choice');$savedLink=$body;
|
||||
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body===$savedLink,'HTTP reload returns exact stored URL without fresh noise');
|
||||
|
||||
@@ -29,12 +29,12 @@ $runtimeBefore = hash_file('sha256', $runtimeFile);
|
||||
$builder = new LinkBuilder($linkStore);
|
||||
$empty = ['selection'=>null, 'url'=>null, 'saved_at'=>null];
|
||||
linkCheck($builder->get('first') === $empty, 'first opening has no implicit saved choice or generated URL');
|
||||
$selection = ['indpic'=>'9','theme'=>'2','degr'=>'3','reg'=>'1','banch'=>'rnd','tp'=>'4','cons'=>'5','focus'=>'rnd'];
|
||||
$selection = ['indpic'=>'9','theme'=>'2','degr'=>'3','reg'=>'1','banch'=>'rnd','tp'=>'4','cons'=>'5','focus'=>'rnd','part'=>'rnd'];
|
||||
$saved = $builder->save('first', $selection);
|
||||
linkCheck($saved['selection'] === $selection && is_string($saved['saved_at']), 'explicit save returns complete independent selection and timestamp');
|
||||
linkCheck(str_starts_with($saved['url'], 'https://first.example/?'), 'URL uses saved target HTTPS origin');
|
||||
parse_str(parse_url($saved['url'], PHP_URL_QUERY), $parameters);
|
||||
linkCheck(array_keys($parameters) === array_keys($selection), 'URL contains all eight canonical UTM keys');
|
||||
linkCheck(array_keys($parameters) === array_keys($selection), 'URL contains all nine canonical UTM keys');
|
||||
foreach ($selection as $key=>$value) {
|
||||
$digit = $value === 'rnd' ? '0' : $value;
|
||||
linkCheck((bool)preg_match('/^[a-zA-Z0-9]{4}' . $digit . '[a-zA-Z0-9]+$/D', $parameters[$key]), "{$key} has four ASCII prefix characters, selected digit and noise suffix");
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/** api#347: общий редактор, копирование и старое приватное состояние. */
|
||||
declare(strict_types=1);
|
||||
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) {
|
||||
$invalid=Config::decode('{"utm":{"part":'.$group.'}}');$rejected=false;
|
||||
try {Config::validate($invalid,static fn()=>true);}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');
|
||||
$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');
|
||||
$notes->save(['marker'=>'part','text'=>'Видимость ссылки на сайте','version'=>$loaded['part']['version']]);
|
||||
partCheck($notes->all()['part']['text']==='Видимость ссылки на сайте','part note saves with CAS');
|
||||
$store->write('targets.json',['fixture'=>['id'=>'fixture','domain'=>'fixture.example']]);
|
||||
$oldSelection=['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1'];
|
||||
$params=[];foreach($oldSelection as $key=>$value)$params[$key]='abcd'.$value.'abcdefabcdef';
|
||||
$store->write('link-builder-fixture.json',['selection'=>$oldSelection,'url'=>'https://fixture.example/?'.http_build_query($params),'saved_at'=>'2026-10-28T01:00:00+00:00']);
|
||||
$builder=new LinkBuilder($store);$saved=$builder->get('fixture');
|
||||
partCheck($saved['selection']===$oldSelection+['part'=>'1'],'old saved builder choice gains default1');
|
||||
parse_str(parse_url($saved['url'],PHP_URL_QUERY),$newParams);
|
||||
partCheck(array_intersect_key($newParams,$params)===$params&&$newParams['part'][4]==='1','migration preserves old noise and adds masked part');
|
||||
partCheck($builder->get('fixture')===$saved,'migrated builder reload preserves exact noise');
|
||||
foreach(['1','2','rnd'] as $part)partCheck($builder->save('fixture',array_replace($saved['selection'],['part'=>$part]))['selection']['part']===$part,'builder supports part '.$part);
|
||||
try{$builder->save('fixture',array_replace($saved['selection'],['part'=>'3']));throw new RuntimeException('invalid builder part accepted');}catch(Problem){}
|
||||
echo "Part admin contract complete\n";
|
||||
|
||||
@@ -16,7 +16,7 @@ const fs=require('node:fs');
|
||||
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();
|
||||
await page.locator('#workspace').waitFor({state:'visible'});
|
||||
assert.equal(await page.locator('#workspace > section').first().getAttribute('id'),'overview','saved runtime overview is first section');
|
||||
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),8);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0);
|
||||
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),9);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0);
|
||||
const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
|
||||
const before=await Promise.all(['a','b'].map(read));
|
||||
await page.locator('#builder-target').selectOption('a');
|
||||
|
||||
@@ -24,11 +24,11 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st
|
||||
try{
|
||||
await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
|
||||
await page.locator('#overview-table tr[data-overview-utm=reg] td .matrix-total').first().waitFor({timeout:6000});
|
||||
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);}
|
||||
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);}
|
||||
const state=await request('state');csrf=state.csrf;const snapshots=await Promise.all(state.targets.map(async t=>{const r=await page.request.get('/api/config?target='+t.id).then(r=>r.json());return {id:t.id,...r};}));const jobsBefore=(await request('jobs')).jobs.map(j=>j.id).sort();
|
||||
const enumMap={theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
|
||||
const alias={reg:['registration'],banch:['chob','bchoice']};
|
||||
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>String(w));assert.deepEqual(await cell.locator('input[type=number]').evaluateAll(ns=>ns.map(n=>n.value)),expected);assert.equal(await cell.locator('.matrix-percent').count(),0);assert.equal(await cell.locator('.matrix-total').innerText(),'Сумма: '+sum+'%');assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
|
||||
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>String(w));assert.deepEqual(await cell.locator('input[type=number]').evaluateAll(ns=>ns.map(n=>n.value)),expected);assert.equal(await cell.locator('.matrix-percent').count(),0);assert.equal(await cell.locator('.matrix-total').innerText(),'Сумма: '+sum+'%');assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
|
||||
await enterEdit();const draft='Заметка UI — '+Date.now()+'\n<img src=x onerror=alert(1)>\nТолько обычный текст';await textarea().fill(draft);const handle=await textarea().elementHandle();await textarea().evaluate(n=>{document.querySelector('#overview-refresh').click();n.focus();n.setSelectionRange(7,11);});await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);assert.equal(await textarea().inputValue(),draft);assert.deepEqual(await handle.evaluate(n=>({connected:n.isConnected,focused:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focused:true,start:7,end:11});
|
||||
await save();assert.equal((await request('utm-notes')).notes.reg.text,draft);assert.equal(await row().locator('img').count(),0);await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await row().getByText(draft,{exact:true}).waitFor();
|
||||
const newer=draft+'\nСохранено после начала старого GET';
|
||||
|
||||
@@ -5,9 +5,9 @@ use PrelandAdmin\{Notes,Problem,Store};
|
||||
function notesCheck(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo "PASS {$message}\n";}
|
||||
function notesReject(callable $callback,int $status,string $message): void {try{$callback();}catch(Problem $e){notesCheck($e->status===$status,$message);return;}throw new RuntimeException('Accepted invalid operation: '.$message);}
|
||||
notesCheck(class_exists(Notes::class),'Notes service is available');
|
||||
$root=sys_get_temp_dir().'/utm-notes-'.bin2hex(random_bytes(8));$store=new Store($root);$notes=new Notes($store);$markers=['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
$root=sys_get_temp_dir().'/utm-notes-'.bin2hex(random_bytes(8));$store=new Store($root);$notes=new Notes($store);$markers=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
|
||||
$runtime=$root.'/runtime.json';file_put_contents($runtime,'{"host":"untouched.example","utm":{}}');$runtimeHash=hash_file('sha256',$runtime);
|
||||
$all=$notes->all();notesCheck(array_keys($all)===$markers&&count(array_filter($all,static fn($n)=>$n['text']===''&&$n['saved_at']===null&&is_string($n['version'])))===8,'GET contract provides all eight empty notes with stable versions');
|
||||
$all=$notes->all();notesCheck(array_keys($all)===$markers&&count(array_filter($all,static fn($n)=>$n['text']===''&&$n['saved_at']===null&&is_string($n['version'])))===9,'GET contract provides all nine empty notes with stable versions');
|
||||
notesCheck(!file_exists($root.'/utm-notes.json')&&$notes->all()===$all,'initial read does not create state or regenerate versions');
|
||||
$text="Общее описание для всех доменов\n<script>alert('literal')</script>\tпример\r\n😀";
|
||||
$saved=$notes->save(['marker'=>'theme','text'=>$text,'version'=>$all['theme']['version']]);notesCheck($saved['text']===$text&&$saved['version']!==$all['theme']['version']&&is_string($saved['saved_at']),'save stores exact multiline Unicode plaintext with new revision and timestamp');
|
||||
@@ -30,7 +30,7 @@ $env=getenv();$env['ADMIN_STATE_DIR']=$root;$env['ADMIN_COOKIE_SECURE']='0';$env
|
||||
function notesRequest(string $method='GET',?array $data=null,?string $csrf=null,string $path='/api/utm-notes'): array {global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$data===null?'':json_encode((object)$data,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$h=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$h[0]??'',$m);foreach($h as $line)if(str_starts_with(strtolower($line),'set-cookie: '))$cookie=explode(';',substr($line,12))[0];return [(int)($m[1]??0),json_decode($raw,true,64,JSON_THROW_ON_ERROR)];}
|
||||
try{$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);break;}usleep(20000);}while(microtime(true)<$until);
|
||||
[$status]=notesRequest();notesCheck($status===401,'HTTP notes require authentication');[$status,$login]=notesRequest('POST',['username'=>'notes-fixture','password'=>'notes-fixture-password'],null,'/api/login');notesCheck($status===200,'HTTP fixture login succeeds');$csrf=$login['csrf'];
|
||||
[$status,$body]=notesRequest();notesCheck($status===200&&array_keys($body['notes'])===$markers,'HTTP GET returns eight global note objects without target');$version=$body['notes']['focus']['version'];
|
||||
[$status,$body]=notesRequest();notesCheck($status===200&&array_keys($body['notes'])===$markers,'HTTP GET returns nine global note objects without target');$version=$body['notes']['focus']['version'];
|
||||
[$status]=notesRequest('POST',['marker'=>'focus','text'=>'csrf','version'=>$version]);notesCheck($status===403,'HTTP notes mutation requires CSRF');
|
||||
[$status,$body]=notesRequest('POST',['marker'=>'focus','text'=>'HTTP global note','version'=>$version],$csrf);notesCheck($status===200&&$body['note']['text']==='HTTP global note','HTTP POST saves exact note response');$httpSaved=$body['note'];
|
||||
[$status,$body]=notesRequest();notesCheck($body['notes']['focus']===$httpSaved,'HTTP reload restores note across requests');
|
||||
|
||||
Reference in new issue
Block a user