diff --git a/README.md b/README.md index a8527ec..7f9afea 100644 --- a/README.md +++ b/README.md @@ -266,4 +266,6 @@ HTTP не предоставляет inventory каталога: лишние ф `admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска. Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`, -`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска. \ No newline at end of file +`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска. + +api#347: part — девятая UTM, пятый символ1/2, default1. Preland только передаёт её на сайт и никогда не показывает ссылку «Для бизнеса». Отсутствующая группа/веса сохраняют fallback1; случайный выбор использует существующий порядок и настроенные веса. Runtime config автоматически не дополняется. Старые сохранённые ссылки конструктора получают part1 без изменения шума остальных меток; старые заметки читаются с пустой заметкой part. diff --git a/admin/README.md b/admin/README.md index 6c95019..6f453ac 100644 --- a/admin/README.md +++ b/admin/README.md @@ -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. diff --git a/admin/composer.json b/admin/composer.json index 6c6d29f..ede20a1 100644 --- a/admin/composer.json +++ b/admin/composer.json @@ -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"} } diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js index 7e578b9..8c37d64 100644 --- a/admin/public/assets/admin.js +++ b/admin/public/assets/admin.js @@ -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)); } diff --git a/admin/src/Config.php b/admin/src/Config.php index 2dd823e..fe0b566 100644 --- a/admin/src/Config.php +++ b/admin/src/Config.php @@ -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 должен быть номером.'); diff --git a/admin/src/Copy.php b/admin/src/Copy.php index 22b238f..18c5ecd 100644 --- a/admin/src/Copy.php +++ b/admin/src/Copy.php @@ -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) {} diff --git a/admin/src/LinkBuilder.php b/admin/src/LinkBuilder.php index 5c67667..6290056 100644 --- a/admin/src/LinkBuilder.php +++ b/admin/src/LinkBuilder.php @@ -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; diff --git a/admin/src/Notes.php b/admin/src/Notes.php index 3985b86..db80866 100644 --- a/admin/src/Notes.php +++ b/admin/src/Notes.php @@ -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]; diff --git a/admin/tests/backend-http.php b/admin/tests/backend-http.php index 990f441..2e2e402 100644 --- a/admin/tests/backend-http.php +++ b/admin/tests/backend-http.php @@ -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'); diff --git a/admin/tests/link-builder.php b/admin/tests/link-builder.php index 3b16646..1d4c003 100644 --- a/admin/tests/link-builder.php +++ b/admin/tests/link-builder.php @@ -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"); diff --git a/admin/tests/part.php b/admin/tests/part.php new file mode 100644 index 0000000..f791393 --- /dev/null +++ b/admin/tests/part.php @@ -0,0 +1,36 @@ +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"; + diff --git a/admin/tests/ui-link-builder.cjs b/admin/tests/ui-link-builder.cjs index 9f72ded..2a7ac3d 100644 --- a/admin/tests/ui-link-builder.cjs +++ b/admin/tests/ui-link-builder.cjs @@ -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'); diff --git a/admin/tests/ui-notes.cjs b/admin/tests/ui-notes.cjs index 36ad431..05ca2d1 100644 --- a/admin/tests/ui-notes.cjs +++ b/admin/tests/ui-notes.cjs @@ -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;indexObject.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;indexObject.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\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'; diff --git a/admin/tests/utm-notes.php b/admin/tests/utm-notes.php index 2c45b62..4fa4e29 100644 --- a/admin/tests/utm-notes.php +++ b/admin/tests/utm-notes.php @@ -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\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'); diff --git a/components/utm-codec.php b/components/utm-codec.php index 639592a..14ca457 100644 --- a/components/utm-codec.php +++ b/components/utm-codec.php @@ -4,7 +4,7 @@ declare(strict_types=1); function prelandUtmEnums(): array { - return ['theme'=>['1'=>'dark','2'=>'light'], 'reg'=>['1'=>'before','2'=>'after'], 'banch'=>['1'=>'b','2'=>'a'], 'focus'=>['1'=>'face','2'=>'center']]; + return ['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']]; } /** Совместимость host-config при доставке кода до точечной миграции. */ @@ -36,7 +36,7 @@ function prelandNumericUtmConfig(array $config): array function prelandResolveUtm(array $query, array $config): array { $config=prelandNumericUtmConfig($config); - $defaults=['indpic'=>'1','theme'=>'2','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1']; + $defaults=['indpic'=>'1','theme'=>'2','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1','part'=>'1']; $enums=prelandUtmEnums(); $digits=[]; $context=$query; foreach (['registration','bchoice','chob','chb'] as $old) unset($context[$old]); foreach ($defaults as $key=>$fallback) { @@ -44,7 +44,7 @@ function prelandResolveUtm(array $query, array $config): array $settings=$config['utm'][$key] ?? []; $valid=static fn($digit): bool => preg_match('/^[1-9]$/D',(string)$digit)===1 && (!isset($enums[$key]) || isset($enums[$key][$digit])) - && (in_array($key,['cons','theme','reg','banch','focus'],true) || isset($settings['weights'][$digit])); + && (in_array($key,['cons','theme','reg','banch','focus','part'],true) || isset($settings['weights'][$digit])); $default=(string)($settings['default'] ?? $fallback); if (!$valid($default)) $default=$fallback; $value=$query[$key] ?? null; diff --git a/components/utm-handoff.php b/components/utm-handoff.php index 536415d..f6b5f3c 100644 --- a/components/utm-handoff.php +++ b/components/utm-handoff.php @@ -2,8 +2,8 @@ /** api#326: новый шум при каждой навигации; recovery хранит выбранные цифры. */ return <<<'JS' (function () { - const keys = ['indpic','theme','degr','reg','banch','tp','cons','focus']; - const enums = {theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}}; + const keys = ['indpic','theme','degr','reg','banch','tp','cons','focus','part']; + const enums = {theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'},part:{'1':'1','2':'2'}}; const legacy = {reg:'registration',banch:'bchoice'}; const valid = (key, value) => /^[1-9]$/.test(value || '') && (!enums[key] || Object.values(enums[key]).includes(value)); function restore(meta, defaults) { diff --git a/index.php b/index.php index 80c2f6c..e413669 100644 --- a/index.php +++ b/index.php @@ -17,7 +17,7 @@ Yii::init($config['default_language'] ?? 'ru'); $config = prelandNumericUtmConfig($config); $resolvedUtm = prelandResolveUtm($_GET, $config); $UTM = $resolvedUtm['context']; -$get_params = array_diff_key($_GET, array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','registration','bchoice','chob','chb','key'], true)); +$get_params = array_diff_key($_GET, array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','part','registration','bchoice','chob','chb','key'], true)); // Числа — внутренний snapshot для recovery. Публичная оболочка создаётся при навигации. $redirect_digits = $resolvedUtm['digits']; $themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light'); diff --git a/tests/check-numeric-utm.php b/tests/check-numeric-utm.php index 986bef0..9d4d497 100644 --- a/tests/check-numeric-utm.php +++ b/tests/check-numeric-utm.php @@ -17,7 +17,7 @@ foreach(['2','rnd','dark','abcdx','abc',[],null,'abcd0'] as $value) { if(prelandResolveUtm(["theme"=>"абвг2"],$config)["digits"]["theme"]!=="2")throw new RuntimeException("Unicode fifth symbol"); for($i=0;$i<50;$i++) if(prelandResolveUtm(['indpic'=>'abcd0'],$config)['digits']['indpic']!=='2')throw new RuntimeException('0 нарушил веса'); $settings=json_decode(file_get_contents(dirname(__DIR__).'/config.json'),true); -$defaults=['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'2','cons'=>'2','focus'=>'1']; +$defaults=['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'2','cons'=>'2','focus'=>'1','part'=>'1']; foreach(array_keys($defaults) as $key) foreach(['1','rnd','abcdx','abc',[],null,"\xffabcd2"] as $invalid) { $resolved=prelandResolveUtm([$key=>$invalid,'registration'=>'before','bchoice'=>'b','chb'=>'a'],$settings); if($resolved['digits'][$key]!==$defaults[$key])throw new RuntimeException('Открытый/invalid URL изменил '.$key); diff --git a/tests/check-part-utm.php b/tests/check-part-utm.php new file mode 100644 index 0000000..add94a4 --- /dev/null +++ b/tests/check-part-utm.php @@ -0,0 +1,22 @@ +'1','abcd2'=>'2','абвг2'=>'2','abcd0'=>'1','abcd9'=>'1','2'=>'1','abcdx'=>'1','abc'=>'1'] as $raw=>$expected) { + $value=prelandResolveUtm(['part'=>$raw,'utm_source'=>'keep'],[]); + partCheck(($value['digits']['part']??null)===$expected && ($value['context']['part']??null)===$expected, 'part: fifth symbol / default1: '.$raw); + partCheck($value['context']['utm_source']==='keep','foreign query preserved'); +} +foreach ([[],null, false, "\xffabcd2"] as $invalid) partCheck((prelandResolveUtm(['part'=>$invalid],[])['digits']['part']??null)==='1','invalid fallback'); +$config=['utm'=>['part'=>['default'=>'2','weights'=>['1'=>0,'2'=>100]]]]; +for($i=0;$i<200;$i++) partCheck(prelandResolveUtm(['part'=>'abcd0'],$config)['digits']['part']==='2','random follows configured weights'); +partCheck(prelandResolveUtm(['part'=>'abcd1'],$config)['digits']['part']==='1','concrete independent of weights'); +partCheck(prelandResolveUtm([],['utm'=>['part'=>['default'=>'2']]])['digits']['part']==='2','configured default'); +partCheck(prelandResolveUtm(['part'=>'abcd0'],['utm'=>['part'=>['default'=>'1','weights'=>['2'=>0,'1'=>0]]]])['digits']['part']==='2','existing generic zero weights order'); +$_GET=['part'=>'abcd2','utm_source'=>'keep'];$_SERVER['HTTP_HOST']='zoomstage.online'; +ob_start();require dirname(__DIR__).'/index.php';$html=ob_get_clean(); +partCheck(!array_key_exists('part',$get_params),'part is not foreign query'); +partCheck($redirect_digits['part']==='2','handoff snapshot part'); +echo "OK: part numeric/default/random/context/foreign boundary\n"; + diff --git a/tests/check-preland-footer.php b/tests/check-preland-footer.php index b288bb4..1ab71eb 100644 --- a/tests/check-preland-footer.php +++ b/tests/check-preland-footer.php @@ -1,16 +1,16 @@ 'zoomstage.online', 'New-Preland.example:8080' => 'new-preland.example', 'www.example.test.' => 'www.example.test', '' => 'fixture.local', ] as $host => $expected) { - $html = shell_exec(PHP_BINARY . ' ' . escapeshellarg(__DIR__ . '/render-preland.php') . ' ' . escapeshellarg($host) . ' ' . escapeshellarg($theme)); + $html = shell_exec(PHP_BINARY . ' ' . escapeshellarg(__DIR__ . '/render-preland.php') . ' ' . escapeshellarg($host) . ' ' . escapeshellarg($theme) . ' ' . escapeshellarg($part) . ' ' . escapeshellarg($lang)); if (!preg_match('~~s', $html, $match)) throw new RuntimeException('Подвал не найден'); $footer = $match[0]; - foreach (['Наши реквизиты', 'payment.svg', 'ИНН:', 'ОГРНИП:', 'ИП Иванов', 'ИП Кардаполова'] as $forbidden) { + foreach (['Наши реквизиты', 'payment.svg', 'ИНН:', 'ОГРНИП:', 'ИП Иванов', 'ИП Кардаполова', 'Для бизнеса', '/public/api-pricing'] as $forbidden) { if (str_contains($footer, $forbidden)) throw new RuntimeException('В подвале остался ' . $forbidden); } if (!str_contains($footer, date('Y') . '© ' . $expected)) throw new RuntimeException('Неправильный домен копирайта: ' . $host); diff --git a/tests/check-utm-redirect-contract.php b/tests/check-utm-redirect-contract.php index 7848beb..94da22b 100644 --- a/tests/check-utm-redirect-contract.php +++ b/tests/check-utm-redirect-contract.php @@ -45,7 +45,7 @@ if ($params !== $expected) { exit(1); } -if ($redirect_digits !== ['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'2','cons'=>'2','focus'=>'1']) { +if ($redirect_digits !== ['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'2','cons'=>'2','focus'=>'1','part'=>'1']) { throw new RuntimeException('Type-in обязан сохранить полный resolved numeric snapshot для handoff/recovery.'); } diff --git a/tests/e2e/utm-handoff.cjs b/tests/e2e/utm-handoff.cjs index 537bc9d..507aa66 100644 --- a/tests/e2e/utm-handoff.cjs +++ b/tests/e2e/utm-handoff.cjs @@ -6,15 +6,15 @@ const source = fs.readFileSync('/app/components/utm-handoff.php','utf8').replace const context = {window:{},URLSearchParams,Uint8Array,crypto:require('node:crypto').webcrypto}; vm.runInNewContext(source,context); const handoff=context.window.ZoomzillaUtmHandoff; -const defaults={indpic:'1',theme:'1',degr:'1',reg:'2',banch:'2',tp:'1',cons:'2',focus:'1'}; -const selected={indpic:'2',theme:'2',degr:'2',reg:'1',banch:'1',tp:'2',cons:'3',focus:'2'}; +const defaults={indpic:'1',theme:'1',degr:'1',reg:'2',banch:'2',tp:'1',cons:'2',focus:'1',part:'1'}; +const selected={indpic:'2',theme:'2',degr:'2',reg:'1',banch:'1',tp:'2',cons:'3',focus:'2',part:'2'}; const foreign='utm_source=abcd9same&subid=%D1%82%D0%B5%D1%81%D1%82+%2B+%26&id=42&stage=keep'; for(const meta of [ {redirectUtm:foreign,utmDigits:selected}, {redirectUtm:foreign,utmDigits:{...selected,banch:undefined,chob:'1'}}, - {redirectUtm:foreign+'&indpic=abcd2&theme=абвг2°r=abcd2®=abcd1&chob=abcd1&tp=abcd2&cons=abcd3&focus=abcd2'}, - {redirectUtm:foreign+'&indpic=2&theme=light°r=2®istration=before&bchoice=b&tp=2&cons=3&focus=center&chb=a'}, - {redirectUtm:foreign+'&indpic=abcd2&theme=абвг2°r=abcd2®=abcd1&banch=abcd1&tp=abcd2&cons=abcd3&focus=abcd2'} + {redirectUtm:foreign+'&indpic=abcd2&theme=абвг2°r=abcd2®=abcd1&chob=abcd1&tp=abcd2&cons=abcd3&focus=abcd2&part=abcd2'}, + {redirectUtm:foreign+'&indpic=2&theme=light°r=2®istration=before&bchoice=b&tp=2&cons=3&focus=center&part=2&chb=a'}, + {redirectUtm:foreign+'&indpic=abcd2&theme=абвг2°r=abcd2®=abcd1&banch=abcd1&tp=abcd2&cons=abcd3&focus=abcd2&part=abcd2'} ]){ const before=JSON.stringify(meta); const first=new URLSearchParams(handoff.query(meta,defaults)); @@ -35,4 +35,9 @@ for(const meta of [ } const typeIn=new URLSearchParams(handoff.query({redirectUtm:'',utmDigits:defaults},defaults)); for(const [key,digit]of Object.entries(defaults))assert.equal(typeIn.get(key)[4],digit); -console.log('OK: all8 fresh noise/repeated handoff, legacy recovery, type-in, foreign/technical values'); +console.log('OK: all9 fresh noise/repeated handoff, legacy recovery, type-in, foreign/technical values'); +for (const part of ['abcd9','abcd0','bad']) { + const query = new URLSearchParams(handoff.query({redirectUtm:'part='+part},defaults)); + assert.equal(query.get('part')[4],'1'); +} +assert.equal(new URLSearchParams(handoff.query({utmDigits:{...defaults,part:'9'}},defaults)).get('part')[4],'1'); diff --git a/tests/render-preland.php b/tests/render-preland.php index a29d589..e46bb17 100644 --- a/tests/render-preland.php +++ b/tests/render-preland.php @@ -3,4 +3,6 @@ if (PHP_SAPI !== 'cli') { http_response_code(404); exit; } $_SERVER['HTTP_HOST'] = $argv[1] ?? 'zoomstage.online'; $_SERVER['SERVER_NAME'] = 'fixture.local'; $_GET = ['theme' => 'abcd' . (($argv[2] ?? 'light') === 'dark' ? '1' : '2'), 'indpic' => 'abcd1']; +if (isset($argv[3])) $_GET['part'] = $argv[3]; +if (isset($argv[4])) $_GET['lang'] = $argv[4]; require dirname(__DIR__) . '/index.php';