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
+6
-3
@@ -21,7 +21,7 @@
|
||||
явно загрузить сохранённое либо отменить черновик. Версии CAS остаются внутренними;
|
||||
даты заметок и версии конфигов в таблице не показываются. Дата последнего чтения с хостинга видна у каждого домена.
|
||||
|
||||
Под таблицей — копирование сохранённых host и восьми канонических UTM из одного
|
||||
Под таблицей — копирование сохранённых host и девяти канонических UTM из одного
|
||||
источника в несколько получателей. Источник исключён из получателей. Черновики
|
||||
источника и выбранных получателей сначала нужно сохранить или отменить. Неизвестные
|
||||
поля, реквизиты, заметки и файлы ZIP не копируются. Отсутствующая группа источника
|
||||
@@ -36,7 +36,7 @@
|
||||
не создаёт задания; сохранённый выбор доступен и при недоступном хостинге.
|
||||
|
||||
`GET /api/link-builder?target=ID` читает выбор, `POST /api/link-builder` сохраняет
|
||||
`{target,selection}` с ровно восемью каноническими UTM-ключами. Действуют общий вход
|
||||
`{target,selection}` с ровно девятью каноническими UTM-ключами. Действуют общий вход
|
||||
и CSRF. Состояние находится в приватном `state/link-builder-ID.json` с режимом0600.
|
||||
При переименовании домена меняется origin ссылки, выбор и сохранённые метки остаются.
|
||||
Неизвестный в текущем config номер сопровождается предупреждением;
|
||||
@@ -54,7 +54,7 @@
|
||||
Черновик сохраняется при обновлении колонок или ошибке запроса. При конфликте
|
||||
сохранения нужно явно загрузить серверный текст; чужой текст молча не заменяется.
|
||||
|
||||
`GET /api/utm-notes` читает восемь заметок, `POST /api/utm-notes` принимает
|
||||
`GET /api/utm-notes` читает девять заметок, `POST /api/utm-notes` принимает
|
||||
`{marker,text,version}`. Авторизация/CSRF общие; версия отдельная у каждой заметки,
|
||||
запись защищена CAS. Plain text до10000 Unicode-символов, пустой текст очищает
|
||||
заметку. Файл `state/utm-notes.json` приватный0600, хранится независимо от
|
||||
@@ -268,3 +268,6 @@ Config/Transport проверяют единый контракт для Save, C
|
||||
GET/POST/DELETE /api/utm-presets требуют вход; записи требуют CSRF. POST принимает только name и utm (канонические группы, только default/weights). DELETE принимает только id. Копирование POST /api/copy принимает preset и targets с ID/версией каждого получателя; прежний источник-домен больше не поддерживается. Очередь фиксирует собственный UTM-снимок, поэтому удаление набора или любого подключения-источника не влияет на задание. Получателем может быть любой доступный домен. Основной сайт и заметки сохраняются, отсутствующая группа набора не удаляет группу получателя. CAS, резерв и подтверждённое перечитывание сохранены.
|
||||
|
||||
Проверки: tests/utm-presets.php, обновлённый tests/copy.php и ui-utm-presets.cjs; helper preset-ui-helper.cjs адаптирует прежние copy-регрессии к независимому источнику. UI покрывает черновик без публикации, имена/дубликаты, два получателя, reload, гонку list GET/delete, 401/relogin, отмену удаления и API-удаление, мобильную ширину. Тестовые удаления выполняются только над созданными этим тестом наборами.
|
||||
|
||||
|
||||
api#347: part — девятая UTM, пятый символ1/2, default1. Поддерживается редактором, именованными UTM-наборами, копированием, заметками и конструктором. Preland только передаёт метку, ссылка «Для бизнеса» там не добавляется. Runtime config автоматически не дополняется. Старые сохранённые ссылки получают независимый part1 без изменения остальных query-шумов; recovery использует общий приоритет сохранённого snapshot перед текущим resolved fallback.
|
||||
+1
-1
@@ -4,5 +4,5 @@
|
||||
"license": "proprietary",
|
||||
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"},
|
||||
"config": {"sort-packages": true},
|
||||
"scripts": {"test": ["php tests/utm-presets.php", "php tests/manual-variants.php", "php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"}
|
||||
"scripts": {"test": ["php tests/utm-presets.php", "php tests/manual-variants.php", "php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php", "php tests/part.php"], "test:worker": "php tests/backend-worker.php"}
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const state = {sessionGeneration:0,csrf:'',allTargets:[],targets:[],targetsEpoch:0,targetsBusy:false,source:'',reads:new Map(),jobs:[],loading:0};
|
||||
const markers=['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
const markers=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
|
||||
const enums={theme:{'1':'dark','2':'light'},reg:{'1':'before','2':'after'},banch:{'1':'b','2':'a'},focus:{'1':'face','2':'center'}};
|
||||
const descriptions={theme:{'1':'тёмная','2':'светлая'},reg:{'1':'ввод имейл → подтверждение PIN → оплата','2':'ввод имейл → оплата → подтверждение PIN'},banch:{'1':'нет pop-up','2':'есть pop-up'},focus:{'1':'лицо, ближайшее к центру','2':'геометрический центр'}};
|
||||
const descriptions={theme:{'1':'тёмная','2':'светлая'},reg:{'1':'ввод имейл → подтверждение PIN → оплата','2':'ввод имейл → оплата → подтверждение PIN'},banch:{'1':'нет pop-up','2':'есть pop-up'},focus:{'1':'лицо, ближайшее к центру','2':'геометрический центр'},part:{'1':'показывать ссылку «Для бизнеса» на сайте','2':'скрывать ссылку «Для бизнеса» на сайте'}};
|
||||
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
|
||||
// Read-only runtime snapshots and independent constructor drafts never enter state.reads/fields.
|
||||
const overview={entries:new Map(),pending:new Map(),serial:0};
|
||||
@@ -15,7 +15,7 @@
|
||||
const keys=[marker,...(marker==='reg'?['registration']:marker==='banch'?['chob','bchoice']:[])].filter(key=>Object.hasOwn(config?.utm||{},key));
|
||||
return {key:keys[0],value:keys.length?config.utm[keys[0]]:null,conflict:keys.length>1?keys:[]};
|
||||
}
|
||||
function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)?digit:null;}
|
||||
function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)&&(marker!=='part'||['1','2'].includes(digit))?digit:null;}
|
||||
const utmNotes={entries:new Map(),loading:false,error:'',serial:0};
|
||||
function noteEntry(marker){
|
||||
if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=Math.max(1,state.targets.length);return entry;}
|
||||
@@ -233,7 +233,7 @@
|
||||
const entry=builder.entries.get(builder.target);$('builder-fields').replaceChildren();$('builder-result').hidden=true;$('builder-warnings').replaceChildren();$('builder-save').disabled=true;
|
||||
if(!builder.target){$('builder-state').textContent='Выберите домен.';return;}
|
||||
if(!entry?.ready){$('builder-state').textContent=entry?.error?'Ошибка чтения выбора: '+entry.error:'Чтение последнего выбора…';if(entry?.error)$('builder-fields').append(button('Повторить чтение выбора',()=>openBuilder(builder.target,true)));return;}
|
||||
for(const marker of markers){const values=[...Array.from({length:9},(_,i)=>String(i+1)),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)})));
|
||||
for(const marker of markers){const values=[...(marker==='part'?['1','2']:Array.from({length:9},(_,i)=>String(i+1))),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)})));
|
||||
if(entry.draft[marker]&&!values.includes(entry.draft[marker]))select.append(el('option',{value:entry.draft[marker],text:'Недопустимый сохранённый выбор: '+entry.draft[marker]}));select.value=entry.draft[marker]||'';select.disabled=entry.saving;
|
||||
select.addEventListener('change',()=>{entry.draft[marker]=select.value;entry.error='';updateBuilderState();});$('builder-fields').append(labelControl(marker,select));
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ final class Config
|
||||
}
|
||||
private static function adaptChange(\stdClass $config,array $change): array {
|
||||
$path=$change['path']??[];if(($path[0]??null)!=='utm'||!isset($path[1]))return $change;
|
||||
$aliases=['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
|
||||
$aliases=['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
|
||||
$key=$path[1];$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key]??$key;$settings=$config->utm??new \stdClass();$existing=[];
|
||||
foreach($aliases[$canonical]??[$canonical] as $alias)if(isset($settings->$alias))$existing[]=$alias;
|
||||
if(count($existing)>1)throw new Problem("Конфликт UTM-ключей {$canonical}. Перечитайте и явно устраните неоднозначность.");
|
||||
@@ -65,11 +65,11 @@ final class Config
|
||||
$utm=$config->utm ?? new \stdClass();
|
||||
if (!$utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
|
||||
foreach (['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'] as $old=>$new) if (isset($utm->$old,$utm->$new)) throw new Problem("Конфликт старого {$old} и нового {$new} UTM-ключа.");
|
||||
$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
|
||||
$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
|
||||
$warnings=[];
|
||||
foreach (get_object_vars($utm) as $key=>$group) {
|
||||
$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key] ?? $key;
|
||||
if (!in_array($canonical,['indpic','theme','degr','reg','banch','tp','cons','focus'],true)) continue;
|
||||
if (!in_array($canonical,['indpic','theme','degr','reg','banch','tp','cons','focus','part'],true)) continue;
|
||||
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights ?? null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$key}.");
|
||||
$number=static function($value) use($maps,$canonical): string {
|
||||
if (!is_int($value) && !is_string($value)) throw new Problem('Вариант UTM должен быть номером.');
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@ namespace PrelandAdmin;
|
||||
/** Применяет независимый именованный UTM-снимок, сохраняя остальные поля получателя. */
|
||||
final class Copy
|
||||
{
|
||||
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
|
||||
private const ALIASES = ['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];
|
||||
private const MAPS = ['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center']];
|
||||
private const MAPS = ['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
|
||||
|
||||
public function __construct(private Store $store) {}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace PrelandAdmin;
|
||||
/** Независимый выбор меток: читает реестр целей, пишет только приватное состояние ссылки. */
|
||||
final class LinkBuilder
|
||||
{
|
||||
private const KEYS = ['indpic', 'theme', 'degr', 'reg', 'banch', 'tp', 'cons', 'focus'];
|
||||
private const KEYS = ['indpic', 'theme', 'degr', 'reg', 'banch', 'tp', 'cons', 'focus', 'part'];
|
||||
|
||||
public function __construct(private readonly Store $store) {}
|
||||
|
||||
@@ -22,10 +22,15 @@ final class LinkBuilder
|
||||
|| !is_string($saved['url'] ?? null) || !is_string($saved['saved_at'] ?? null)) {
|
||||
throw new Problem('Повреждено сохранённое состояние конструктора ссылки.', 503);
|
||||
}
|
||||
$selection = $this->selection($saved['selection']);
|
||||
// Старые восемь полей дополняем part=1, сохраняя исходный шум остальных меток.
|
||||
$legacy = count($saved['selection']) === 8 && !array_key_exists('part', $saved['selection']);
|
||||
$selection = $this->selection($legacy ? $saved['selection'] + ['part'=>'1'] : $saved['selection']);
|
||||
$query = parse_url($saved['url'], PHP_URL_QUERY);
|
||||
if (!is_string($query)) throw new Problem('Повреждена сохранённая ссылка.', 503);
|
||||
parse_str($query, $parameters);
|
||||
if ($legacy && array_keys($parameters) === array_slice(self::KEYS, 0, 8)) {
|
||||
$parameters['part'] = bin2hex(random_bytes(2)) . '1' . bin2hex(random_bytes(6));
|
||||
}
|
||||
if (array_keys($parameters) !== self::KEYS) throw new Problem('Повреждена сохранённая ссылка.', 503);
|
||||
foreach ($selection as $key=>$value) {
|
||||
$digit = $value === 'rnd' ? '0' : $value;
|
||||
@@ -35,7 +40,8 @@ final class LinkBuilder
|
||||
}
|
||||
// Переименование цели меняет только origin, сохранённый шум не генерируется заново.
|
||||
$url = $origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
|
||||
if ($saved['url'] !== $url) {
|
||||
if ($saved['url'] !== $url || $legacy) {
|
||||
$saved['selection'] = $selection;
|
||||
$saved['url'] = $url;
|
||||
$this->store->write($name, $saved);
|
||||
}
|
||||
@@ -66,12 +72,13 @@ final class LinkBuilder
|
||||
private function selection(array $input): array
|
||||
{
|
||||
if (count($input) !== count(self::KEYS) || array_diff(array_keys($input), self::KEYS)) {
|
||||
throw new Problem('Передайте ровно восемь параметров конструктора ссылки.');
|
||||
throw new Problem('Передайте ровно девять параметров конструктора ссылки.');
|
||||
}
|
||||
$selection = [];
|
||||
foreach (self::KEYS as $key) {
|
||||
$value = $input[$key] ?? null;
|
||||
if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))) {
|
||||
if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))
|
||||
|| ($key === 'part' && !in_array($value, ['1', '2', 'rnd'], true))) {
|
||||
throw new Problem("Недопустимое значение параметра {$key} конструктора ссылки.");
|
||||
}
|
||||
$selection[$key] = $value;
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@ namespace PrelandAdmin;
|
||||
|
||||
final class Notes
|
||||
{
|
||||
private const MARKERS=['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
private const MARKERS=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
|
||||
public function __construct(private readonly Store $store) {}
|
||||
|
||||
public function all(): array {
|
||||
@@ -29,7 +29,9 @@ final class Notes
|
||||
$notes=[];foreach(self::MARKERS as $marker)$notes[$marker]=['text'=>'','version'=>hash('sha256','utm-notes:'.$marker.':empty'),'saved_at'=>null];return $notes;
|
||||
}
|
||||
$notes=$this->store->read('utm-notes.json',null);
|
||||
if(!is_array($notes)||count($notes)!==8||array_diff(self::MARKERS,array_keys($notes)))throw new Problem('Повреждено приватное состояние заметок.',503);
|
||||
// Старое хранилище без part читаем с пустой заметкой; прежние версии сохраняем.
|
||||
if(is_array($notes)&&count($notes)===8&&!array_key_exists('part',$notes))$notes['part']=['text'=>'','version'=>hash('sha256','utm-notes:part:empty'),'saved_at'=>null];
|
||||
if(!is_array($notes)||count($notes)!==count(self::MARKERS)||array_diff(self::MARKERS,array_keys($notes)))throw new Problem('Повреждено приватное состояние заметок.',503);
|
||||
$ordered=[];
|
||||
foreach(self::MARKERS as $marker){
|
||||
$note=$notes[$marker];
|
||||
|
||||
@@ -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