From 298b29cd52754d0974df542c11b070e8a6a58a91 Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Mon, 21 Sep 2026 03:30:21 +0300 Subject: [PATCH] Expose developer promo creation with three subscription plans (#39) Load the missing promo module, add a prominent creation button and place Promos beside Overview. Use existing AAL2-guarded server RPCs with selected plan, subscription duration and code validity; preserve form input, honest clipboard feedback and scope async responses. Full PR QA succeeded in run 35547013076. No live data, SQL, MFA or unfinished training branch changes. Preserve normal main QA and exact-asset/browser publication verification. --- .github/workflows/deploy-timeweb.yml | 5 ++ public/core/hotfix-v1763.js | 10 +++ public/core/trial-promo-developer-v181.js | 95 ++++++++++++++++++----- public/service-worker.js | 4 +- tests/playwright.config.mjs | 4 +- tests/production-promo-entry.mjs | 21 +++++ tests/promo-entry-fixture.mjs | 54 +++++++++++++ tests/promo-entry.spec.mjs | 55 +++++++++++++ 8 files changed, 225 insertions(+), 23 deletions(-) create mode 100644 tests/production-promo-entry.mjs create mode 100644 tests/promo-entry-fixture.mjs create mode 100644 tests/promo-entry.spec.mjs diff --git a/.github/workflows/deploy-timeweb.yml b/.github/workflows/deploy-timeweb.yml index 8a8221a..0f388af 100644 --- a/.github/workflows/deploy-timeweb.yml +++ b/.github/workflows/deploy-timeweb.yml @@ -43,6 +43,8 @@ jobs: files=( index.html app-runtime.js + core/hotfix-v1763.js + core/trial-promo-developer-v181.js core/account-center-v1780.js core/company-branding.js core/sun-safe.js @@ -130,6 +132,9 @@ jobs: - name: Check published employee session and mobile logout timeout-minutes: 4 run: node tests/production-employee-session.mjs + - name: Check published developer promo entry and plan selection + timeout-minutes: 4 + run: node tests/production-promo-entry.mjs - name: Save production UI verification if: always() uses: actions/upload-artifact@v4 diff --git a/public/core/hotfix-v1763.js b/public/core/hotfix-v1763.js index 884f711..0430fc3 100644 --- a/public/core/hotfix-v1763.js +++ b/public/core/hotfix-v1763.js @@ -26,7 +26,17 @@ dev.open?.(); } + function loadPromoControls(){ + const id='cateriumTrialPromoDeveloperV181Script'; + if(window.CateriumTrialPromoDeveloperV181||document.getElementById(id))return; + const script=document.createElement('script');script.id=id; + script.src='core/trial-promo-developer-v181.js?v=20260921-promo-entry';script.async=true; + script.onerror=()=>{script.remove();console.warn('[Caterium] Не загрузился раздел промокодов.');}; + document.head.append(script); + } + function maintain(){ + loadPromoControls(); patchDeveloperOpen(); } diff --git a/public/core/trial-promo-developer-v181.js b/public/core/trial-promo-developer-v181.js index d9ce450..83031d3 100644 --- a/public/core/trial-promo-developer-v181.js +++ b/public/core/trial-promo-developer-v181.js @@ -1,21 +1,76 @@ +/* Developer invitations, using the existing server-enforced AAL2 RPCs. */ (()=>{ -'use strict'; -if(window.CateriumTrialPromoDeveloperV181)return; -const VERSION='18.1-trial-promo-developer'; -const $=id=>document.getElementById(id),qa=(s,r=document)=>[...r.querySelectorAll(s)]; -const client=()=>window.SunCloudV2?.getClient?.()||null; -const dev=()=>window.SunDeveloperV22||null; -const esc=v=>window.SunSafe?.escapeHTML?window.SunSafe.escapeHTML(String(v??'')):String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -const toast=(t,type='info')=>window.SunEnterprise?.toast?.(t,type,5000); -async function rpc(name,args={}){const c=client();if(!c)throw new Error('Supabase не подключён.');const {data,error}=await c.rpc(name,args);if(error)throw error;return data;} -function fmt(v){if(!v)return'—';try{return new Date(v).toLocaleString('ru-RU',{dateStyle:'short',timeStyle:'short'})}catch(_){return String(v)}} -function ensureStyle(){if($('cateriumPromoDevStyle'))return;const s=document.createElement('style');s.id='cateriumPromoDevStyle';s.textContent=`.ctm-promo-tools{display:grid;grid-template-columns:1.2fr 1.2fr .65fr .65fr .65fr auto;gap:8px;align-items:end;margin-bottom:14px}.ctm-promo-tools label{display:grid;gap:5px;font-size:11px;font-weight:800;color:#686e76}.ctm-promo-tools input,.ctm-promo-tools select{min-height:39px;border:1px solid #d8dbe0;border-radius:9px;padding:7px 9px;background:#fff}.ctm-promo-code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:900;letter-spacing:.04em}.ctm-promo-copy{cursor:pointer}.ctm-promo-note{margin:0 0 12px;color:#747b83;font-size:12px}@media(max-width:1000px){.ctm-promo-tools{grid-template-columns:1fr 1fr}.ctm-promo-tools button{grid-column:1/-1}}`;document.head.appendChild(s);} -function addTab(){const root=$('sun-developer-console-v22');if(!root)return false;const tabs=root.querySelector('[data-dev-tab]')?.parentElement;if(!tabs)return false;if(!tabs.querySelector('[data-dev-tab="promos"]')){const b=document.createElement('button');b.type='button';b.dataset.devTab='promos';b.textContent='Промокоды';tabs.appendChild(b);}return true;} -async function render(){const root=$('sunDevBody');if(!root)return;qa('#sun-developer-console-v22 [data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab==='promos'));try{localStorage.setItem('sunDeveloperActiveTabV22','promos')}catch(_){}root.innerHTML='
Загрузка промокодов…
'; -try{const rows=await rpc('sun_dev_list_trial_promos',{p_limit:300})||[];root.innerHTML=`

Промокоды пробной версии

Сгенерируйте код автоматически или напишите свой. Email можно оставить пустым для универсального кода. По умолчанию код одноразовый.

${rows.map(r=>``).join('')||''}
КодКлиентTrialДействует доИспользованияПоследняя активацияСтатус
${esc(r.client_email||'Любой email')}${Number(r.trial_days||0)} дн.${fmt(r.valid_until)}${Number(r.use_count||0)} / ${Number(r.max_uses||0)}${r.last_redeemed_at?`${fmt(r.last_redeemed_at)}
${esc(r.last_workspace_name||'')} ${esc(r.last_redeemed_email||'')}
`:'—'}
${r.is_active?'Активен':'Отключён'}
Промокодов пока нет.
`; -$('ctmPromoCode').oninput=e=>e.target.value=e.target.value.toUpperCase().replace(/\s+/g,'');$('ctmPromoCreate').onclick=create;qa('[data-copy]',root).forEach(b=>b.onclick=async()=>{try{await navigator.clipboard.writeText(b.dataset.copy);toast('Промокод скопирован.','success')}catch(_){toast('Не удалось скопировать код.','error')}});qa('[data-toggle]',root).forEach(b=>b.onclick=async()=>{b.disabled=true;try{await rpc('sun_dev_set_trial_promo_active',{p_promo:b.dataset.toggle,p_active:b.dataset.active!=='1'});toast('Статус промокода изменён.','success');await render()}catch(e){toast(e?.message||String(e),'error');b.disabled=false}}); -}catch(e){root.innerHTML=`

Не удалось загрузить промокоды

${esc(e?.message||e)}

`;}} -async function create(){const btn=$('ctmPromoCreate');btn.disabled=true;try{const data=await rpc('sun_dev_create_trial_promo',{p_code:String($('ctmPromoCode')?.value||'').trim()||null,p_email:String($('ctmPromoEmail')?.value||'').trim()||null,p_trial_days:Number($('ctmPromoTrial')?.value||14),p_valid_days:Number($('ctmPromoValid')?.value||7),p_max_uses:Number($('ctmPromoUses')?.value||1),p_plan:'full',p_note:null});const code=data?.code||'';try{await navigator.clipboard.writeText(code)}catch(_){}toast(`Промокод ${code} создан${code?' и скопирован':''}.`,'success');await render()}catch(e){toast(e?.message||String(e),'error')}finally{if(btn?.isConnected)btn.disabled=false}} -function bind(){ensureStyle();if(!addTab())return;const root=$('sun-developer-console-v22');if(root&&!root.dataset.promoV181){root.dataset.promoV181='1';root.addEventListener('click',e=>{const b=e.target.closest('[data-dev-tab="promos"]');if(!b)return;e.preventDefault();e.stopImmediatePropagation();render();},true);}const d=dev();if(d&&!d.__promoV181&&typeof d.open==='function'){const original=d.open.bind(d);d.open=async source=>{const r=await original(source);addTab();if(localStorage.getItem('sunDeveloperActiveTabV22')==='promos')await render();return r};Object.defineProperty(d,'__promoV181',{value:true,configurable:true});}} -const obs=new MutationObserver(bind);obs.observe(document.documentElement,{childList:true,subtree:true});bind();setInterval(()=>{if(!document.hidden)bind()},3000);window.CateriumTrialPromoDeveloperV181=Object.freeze({VERSION,render,bind}); -})(); \ No newline at end of file + 'use strict'; + if(window.CateriumTrialPromoDeveloperV181)return; + const VERSION='20260921-promo-entry',TAB='sunDeveloperActiveTabV22'; + const $=id=>document.getElementById(id),cloud=()=>window.SunCloudV2; + const plans={basic:'Базовый',professional:'Профессиональный',full:'Полный'}; + const esc=v=>window.SunSafe.escapeHTML(String(v??'')),fmt=v=>v?new Date(v).toLocaleString('ru-RU'):'—'; + let view=null,frame=0; + const identity=()=>JSON.stringify([cloud()?.getSession?.()?.user?.id,cloud()?.getWorkspace?.()?.id]); + const allowed=()=>Boolean(cloud()?.getSession?.()?.user&&window.SunDeveloperV22?.isPlatformAdmin?.()&&!cloud()?.isSupportMode?.()&&!document.body.classList.contains('sun-cloud-auth-required')); + const selected=()=>allowed()&&$('sun-developer-console-v22')?.classList.contains('on')&&localStorage.getItem(TAB)==='promos'; + const current=v=>view===v&&v.identity===identity()&&selected(); + async function rpc(name,args={}){const c=cloud()?.getClient?.();if(!c||!allowed())throw new Error('Войдите в кабинет разработчика и подтвердите 2FA.');const r=await c.rpc(name,args);if(r.error)throw r.error;return r.data;} + const message=e=>['PGRST202','42883'].includes(e?.code)?'Серверный модуль промокодов недоступен.':String(e?.message||e||'Не удалось выполнить операцию.'); + function styles(){ + if($('cateriumPromoDevStyle'))return; + const s=document.createElement('style');s.id='cateriumPromoDevStyle';s.textContent=` + #ctmPromoOpen{min-height:44px}#ctmPromoPanel{min-width:0}.ctm-promo-tools{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;align-items:end}.ctm-promo-tools label{display:grid;gap:6px;min-width:0;font-size:12px;font-weight:700}.ctm-promo-tools input,.ctm-promo-tools select{box-sizing:border-box;width:100%;min-width:0;min-height:44px;padding:9px;border:1px solid #d8dbe0;border-radius:9px;font:inherit;background:#fff}.ctm-promo-wide{grid-column:1/-1}.ctm-promo-note{font-size:13px;line-height:1.5;color:#697078}.ctm-promo-result{padding:14px;border:1px solid #d5bb77;border-radius:12px;background:#fffaec;margin-top:14px}.ctm-promo-result textarea{box-sizing:border-box;width:100%;min-height:175px;margin:10px 0;padding:10px;resize:vertical;font:13px/1.5 Arial,sans-serif}.ctm-promo-actions{display:flex;flex-wrap:wrap;gap:8px}.ctm-promo-actions button{min-height:44px}.ctm-promo-code{font-family:ui-monospace,monospace;overflow-wrap:anywhere}#ctmPromoError,#ctmPromoListError{color:#a23426}#ctmPromoError:empty,#ctmPromoListError:empty{display:none}.ctm-promo-list{display:grid;gap:10px;margin-top:15px}.ctm-promo-list article{padding:12px;border:1px solid #e0e0dc;border-radius:10px;min-width:0}.ctm-promo-list p{font-size:12px;line-height:1.5;margin:6px 0} + @media(max-width:600px){.ctm-promo-tools{grid-template-columns:minmax(0,1fr)}#ctmPromoOpen{width:100%}.ctm-promo-actions button{flex:1}} + `;document.head.append(s); + } + function invitation(data,args){return `Приглашение в Caterium\nРегистрация: https://app.caterium.ru/\nНа экране входа выберите регистрацию и введите промокод: ${data.code}\nТариф: ${plans[args.p_plan]}\nСрок подписки: ${args.p_trial_days} дн. с активации после подтверждения email\nАктивировать приглашение до: ${fmt(data.valid_until)}${args.p_email?'\nДля email: '+args.p_email:''}`;} + async function copy(text,status){try{await navigator.clipboard.writeText(text);if(status.isConnected)status.textContent='Скопировано.';}catch(_){if(status.isConnected)status.textContent='Выделите и скопируйте текст вручную: автоматическое копирование недоступно.';}} + async function loadList(v){ + const serial=++v.listRequest,list=v.node.querySelector('#ctmPromoList'),error=v.node.querySelector('#ctmPromoListError');error.textContent='';list.setAttribute('aria-busy','true'); + try{ + const rows=await rpc('sun_dev_list_trial_promos',{p_limit:300});if(!current(v)||serial!==v.listRequest)return; + if(!Array.isArray(rows))throw new Error('Сервер вернул неполный список кодов.'); + list.innerHTML=rows.map((r,i)=>`
${esc(r.code)}

${esc(r.client_email||'Любой email')} · Подписка: ${Number(r.trial_days)} дн.

Активировать до: ${esc(fmt(r.valid_until))} · Использовано: ${Number(r.use_count)} / ${Number(r.max_uses)} · ${r.is_active?'Включён':'Отключён'}

`).join('')||'

Промокодов пока нет. Создайте первый выше.

'; + list.querySelectorAll('[data-copy-code]').forEach(b=>b.onclick=()=>copy(rows[Number(b.dataset.copyCode)].code,v.node.querySelector('#ctmPromoCopyStatus'))); + list.querySelectorAll('[data-toggle-code]').forEach(b=>b.onclick=async()=>{const row=rows[Number(b.dataset.toggleCode)];if(!current(v))return;b.disabled=true;try{await rpc('sun_dev_set_trial_promo_active',{p_promo:row.promo_id,p_active:!row.is_active});if(current(v))await loadList(v);}catch(e){if(current(v))error.textContent=message(e);}finally{b.disabled=false;}}); + }catch(e){if(current(v)&&serial===v.listRequest)error.textContent=message(e);}finally{if(serial===v.listRequest)list.removeAttribute('aria-busy');} + } + async function create(v){ + if(!current(v)||v.creating)return; + const form=v.node.querySelector('form');if(!form.reportValidity())return; + const q=id=>v.node.querySelector('#'+id); + const args={p_code:q('ctmPromoCode').value.trim().toUpperCase()||null,p_email:q('ctmPromoEmail').value.trim()||null,p_plan:q('ctmPromoPlan').value,p_trial_days:Number(q('ctmPromoTrial').value),p_valid_days:Number(q('ctmPromoValid').value),p_max_uses:Number(q('ctmPromoUses').value),p_note:null}; + if(!plans[args.p_plan])return; + const button=q('ctmPromoCreate'),error=q('ctmPromoError');v.creating=true;button.disabled=true;error.textContent=''; + try{ + const data=await rpc('sun_dev_create_trial_promo',args);if(!current(v))return; + if(!data?.code)throw new Error('Сервер не вернул созданный код. Проверьте список перед повторной попыткой.'); + q('ctmPromoCreated').innerHTML='
Промокод создан — отправьте приглашение получателю
'; + const text=invitation(data,args);q('ctmInvitationText').value=text;q('ctmInvitationCopy').onclick=()=>copy(text,q('ctmPromoCopyStatus'));q('ctmCodeCopy').onclick=()=>copy(data.code,q('ctmPromoCopyStatus'));void loadList(v); + }catch(e){if(current(v))error.textContent=message(e);}finally{v.creating=false;button.disabled=false;} + } + function render(){ + if(!allowed()||!$('sun-developer-console-v22')?.classList.contains('on'))return; + localStorage.setItem(TAB,'promos');document.querySelectorAll('#sun-developer-console-v22 [data-dev-tab]').forEach(b=>b.classList.toggle('on',b.dataset.devTab==='promos')); + if(view?.identity===identity()){$('sunDevBody')?.replaceChildren(view.node);return;} + const node=document.createElement('section');node.id='ctmPromoPanel';node.className='sun-dev-card'; + node.innerHTML=`

Промокоды для регистрации и подписки

Выберите тариф и срок доступа. Промокод можно сгенерировать автоматически или задать вручную. Срок подписки начинается при активации после подтверждения email, а срок действия кода — при его создании.

Созданные промокоды

`; + const v={node,identity:identity(),creating:false,listRequest:0};view=v;node.querySelector('form').addEventListener('submit',e=>{e.preventDefault();void create(v);});node.querySelector('#ctmPromoReload').onclick=()=>loadList(v);$('sunDevBody')?.replaceChildren(node);void loadList(v); + } + function bind(){ + styles();const root=$('sun-developer-console-v22');if(!root||!allowed())return; + const tabs=root.querySelector('.sun-dev-tabs');if(!tabs)return; + let tab=tabs.querySelector('[data-dev-tab="promos"]');if(!tab){tab=document.createElement('button');tab.type='button';tab.dataset.devTab='promos';tab.textContent='Промокоды';} + const overview=tabs.querySelector('[data-dev-tab="overview"]');if(overview&&overview.nextElementSibling!==tab)overview.after(tab);else if(!tab.parentElement)tabs.prepend(tab); + const actions=root.querySelector('.sun-dev-head-actions');if(actions&&!$('ctmPromoOpen')){const button=document.createElement('button');button.type='button';button.id='ctmPromoOpen';button.className='primary';button.textContent='Создать промокод';actions.prepend(button);} + // A slow response from the previous tab must not replace a live promo form. + if(view&¤t(view)&&!view.node.isConnected)$('sunDevBody')?.replaceChildren(view.node); + } + function schedule(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;bind();});} + function reset(){view?.node.remove();view=null;$('ctmPromoOpen')?.remove();document.querySelector('[data-dev-tab="promos"]')?.remove();} + document.addEventListener('click',e=>{ + if(e.target.closest?.('#ctmPromoOpen,#sun-developer-console-v22 [data-dev-tab="promos"]')){e.preventDefault();e.stopImmediatePropagation();render();} + else if(e.target.closest?.('#sunDevRefresh')&&selected()){e.preventDefault();e.stopImmediatePropagation();if(view)void loadList(view);} + },true); + window.addEventListener('sun:cloud-tenant-changing',reset); + window.addEventListener('sun:cloud-permissions-changed',()=>{if(!allowed()||view?.identity!==identity())reset();schedule();}); + new MutationObserver(schedule).observe(document.documentElement,{childList:true,subtree:true});bind(); + window.CateriumTrialPromoDeveloperV181=Object.freeze({VERSION,render,bind}); +})(); diff --git a/public/service-worker.js b/public/service-worker.js index 39becce..2d96559 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,6 +1,7 @@ -const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery'; +const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery-promo-entry'; const VERSION='20260918-ui-stability'; const CORE=[ + './core/trial-promo-developer-v181.js?v=20260921-promo-entry', './core/banquet-client-menu.js?v=20260920-onepage', './core/training-catalog.js?v=20260920-training', './core/catalog-pricing.js?v=20260919-client-menu','./core/client-menu.css?v=20260919-client-menu', @@ -14,6 +15,7 @@ const CORE=[ './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' ]; const CRITICAL_FRESH=new Set([ + '/core/trial-promo-developer-v181.js','/core/hotfix-v1763.js', '/core/banquet-client-menu.js', '/core/training-catalog.js', '/core/catalog-pricing.js','/core/client-menu.css', diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index c47374d..71a909f 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], + testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], timeout:30000, use:{baseURL:'http://127.0.0.1:4173'}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, projects:[ {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, - {name:'iphone-webkit',testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, + {name:'iphone-webkit',testMatch:['promo-entry.spec.mjs','developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ] diff --git a/tests/production-promo-entry.mjs b/tests/production-promo-entry.mjs new file mode 100644 index 0000000..69785ee --- /dev/null +++ b/tests/production-promo-entry.mjs @@ -0,0 +1,21 @@ +import fs from 'node:fs/promises'; +import {chromium} from '@playwright/test'; +import {bootPromoFixture,exercisePromoCreation} from './promo-entry-fixture.mjs'; +const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru'); +if(base.protocol!=='https:')throw new Error('HTTPS required'); +const output='production-ui-results';await fs.mkdir(output,{recursive:true}); +const browser=await chromium.launch(),results=[]; +try{ + for(const width of [390,1440]){ + const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'}); + try{ + const page=await context.newPage();await bootPromoFixture(page,base.href); + await page.screenshot({path:`${output}/promo-entry-${width}.png`,animations:'disabled'}); + await exercisePromoCreation(page); + await page.locator('#ctmPromoPanel').screenshot({path:`${output}/promo-form-${width}.png`,animations:'disabled'}); + results.push({width,loadedFromApp:true,headerEntry:true,threePlans:true,syntheticCreation:true}); + console.log(`PASS published promo UI ${width}px: real loader, visible entry, three plans, selected duration, synthetic creation`); + }finally{await context.close();} + } + await fs.writeFile(`${output}/promo-entry.json`,JSON.stringify({backend:'synthetic and network-blocked; no real codes created',results},null,2)); +}finally{await browser.close();} diff --git a/tests/promo-entry-fixture.mjs b/tests/promo-entry-fixture.mjs new file mode 100644 index 0000000..cd0b2a6 --- /dev/null +++ b/tests/promo-entry-fixture.mjs @@ -0,0 +1,54 @@ +import {expect} from '@playwright/test'; + +// Only GET requests for public static assets are allowed. No real account, +// promo, subscription or backend is read or written, including on production. +export async function bootPromoFixture(page,base='http://127.0.0.1:4173/'){ + const origin=new URL(base).origin; + await page.route('**/*',route=>{const r=route.request(),u=new URL(r.url());return r.method()==='GET'&&u.origin===origin&&!u.pathname.startsWith('/api/')?route.continue():route.abort();}); + await page.addInitScript(()=>{ + const user={id:'promo-test-user',email:'promo-ui@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'},ws='promo-test-workspace'; + window.promoCalls=[];window.promoRows=[];window.promoCreateError=null;window.promoDelay=false; + localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:ws,localWorkspaceId:ws,tenantStorageReady:true,autoSync:false})); + localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]'); + const features=Object.fromEntries('orders calendar clients catalog_view catalog_edit production shopping stock routes mailings money stats_basic stats_advanced team suppliers print settings branding client_offers offer_templates backups audit users_manage'.split(' ').map(k=>[k,true])); + const sdk={ + auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null}),mfa:{getAuthenticatorAssuranceLevel:async()=>({data:{currentLevel:'aal2'}})}}, + rpc:async(name,args={})=>{ + if(name==='sun_my_workspaces')return {data:[{id:ws,name:'Проверка интерфейса',role:'admin',is_active:true,permissions:{}}]}; + if(name==='sun_subscription_snapshot')return {data:{plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features}}; + if(name==='sun_is_platform_admin')return {data:true}; + if(name==='sun_dev_dashboard')return {data:{}}; + if(name==='caterium_trial_demo_status')return {data:{canInstall:false,canUpgrade:false}}; + if(name==='sun_dev_list_trial_promos'){promoCalls.push({name,args});return {data:structuredClone(promoRows)};} + if(name==='sun_dev_create_trial_promo'){ + promoCalls.push({name,args});if(promoDelay)await new Promise(resolve=>window.completePromo=resolve); + if(promoCreateError)return {error:{message:promoCreateError}}; + const data={promo_id:'test-code-'+promoRows.length,code:args.p_code||'CTM-UI-TEST',trial_days:args.p_trial_days,valid_until:'2099-01-01T00:00:00Z'}; + promoRows.push({...data,client_email:args.p_email,max_uses:args.p_max_uses,use_count:0,is_active:true});return {data}; + } + if(name==='sun_dev_set_trial_promo_active'){promoCalls.push({name,args});const r=promoRows.find(r=>r.promo_id===args.p_promo);if(r)r.is_active=args.p_active;return {data:null};} + return {data:null,error:null}; + },channel:()=>({on(){return this},subscribe(){return this}}),removeChannel(){} + }; + window.supabase={createClient:()=>sdk}; + }); + await page.goto(base,{waitUntil:'domcontentloaded',timeout:60000}); + await expect(page.locator('body > header')).toBeVisible({timeout:20000}); + await page.waitForFunction(()=>window.SunDeveloperV22&&window.SunDeveloperUXV1768&&window.CateriumTrialPromoDeveloperV181); + await page.locator('#sunDeveloperNavV22').click(); + await expect(page.locator('#ctmPromoOpen')).toBeVisible(); +} +export async function exercisePromoCreation(page){ + await page.locator('#ctmPromoOpen').click(); + await expect(page.locator('#ctmPromoPlan')).toBeVisible(); + await expect(page.locator('#ctmPromoPlan option')).toHaveCount(3); + await page.locator('#ctmPromoPlan').selectOption('professional'); + await page.locator('#ctmPromoTrial').fill('30'); + await page.locator('#ctmPromoValid').fill('7'); + await page.locator('#ctmPromoCreate').click(); + await expect(page.locator('#ctmInvitationText')).toHaveValue(/Профессиональный/); + await expect(page.locator('#ctmInvitationText')).toHaveValue(/30 дн/); + await expect(page.locator('#ctmPromoList')).toContainText('CTM-UI-TEST'); + const calls=await page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo')); + expect(calls).toHaveLength(1);expect(calls[0].args).toMatchObject({p_plan:'professional',p_trial_days:30,p_valid_days:7,p_max_uses:1,p_code:null}); +} diff --git a/tests/promo-entry.spec.mjs b/tests/promo-entry.spec.mjs new file mode 100644 index 0000000..227d783 --- /dev/null +++ b/tests/promo-entry.spec.mjs @@ -0,0 +1,55 @@ +import {test,expect} from '@playwright/test'; +import {bootPromoFixture,exercisePromoCreation} from './promo-entry-fixture.mjs'; +test.use({serviceWorkers:'block'}); + +test('the real app loads promo controls and shows an obvious creation entry',async({page})=>{ + await bootPromoFixture(page); + expect(await page.locator('[data-dev-tab="overview"]').evaluate(el=>el.nextElementSibling?.dataset.devTab)).toBe('promos'); + await exercisePromoCreation(page); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); + await page.locator('#ctmPromoReload').click(); + await expect(page.locator('#ctmInvitationText')).toHaveValue(/CTM-UI-TEST/); + await page.locator('[data-toggle-code="0"]').click(); + await expect(page.locator('#ctmPromoList')).toContainText('Отключён'); +}); + +test('all three plans reach the RPC and invalid input sends no creation request',async({page})=>{ + await bootPromoFixture(page);await page.locator('[data-dev-tab="promos"]').click(); + await page.locator('#ctmPromoTrial').fill('0');await page.locator('#ctmPromoCreate').click(); + expect(await page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo').length)).toBe(0); + await page.locator('#ctmPromoTrial').fill('21'); + for(const plan of ['basic','professional','full']){ + await page.locator('#ctmPromoPlan').selectOption(plan);await page.locator('#ctmPromoCreate').click(); + await expect.poll(()=>page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo').at(-1)?.args.p_plan)).toBe(plan); + await expect(page.locator('#ctmPromoCreate')).toBeEnabled(); + } +}); + +test('failed creation is shown as an error and is not automatically retried',async({page})=>{ + await bootPromoFixture(page);await page.locator('#ctmPromoOpen').click(); + await page.evaluate(()=>{promoCreateError='Подтвердите 2FA';});await page.locator('#ctmPromoCreate').click(); + await expect(page.locator('#ctmPromoError')).toContainText('Подтвердите 2FA');await expect(page.locator('#ctmInvitationText')).toHaveCount(0); + expect(await page.evaluate(()=>promoCalls.filter(c=>c.name==='sun_dev_create_trial_promo').length)).toBe(1); + await expect(page.locator('#ctmPromoCreate')).toBeEnabled(); +}); + +test('a delayed creation response cannot take over another tab or account',async({page})=>{ + await bootPromoFixture(page);await page.locator('#ctmPromoOpen').click(); + await page.evaluate(()=>{promoDelay=true;});await page.locator('#ctmPromoCreate').click(); + await expect(page.locator('#ctmPromoCreate')).toBeDisabled(); + await page.locator('[data-dev-tab="overview"]').click();await page.evaluate(()=>completePromo()); + await expect(page.locator('#ctmInvitationText')).toHaveCount(0); + await expect(page.locator('[data-dev-tab="overview"]')).toHaveClass(/on/); + await page.locator('#ctmPromoOpen').click(); + await expect(page.locator('#ctmPromoCreate')).toBeEnabled(); + await page.evaluate(()=>{window.dispatchEvent(new Event('sun:cloud-tenant-changing'));}); + await expect(page.locator('#ctmPromoPanel')).toHaveCount(0); +}); + +test('maintenance does not rebuild a form or lose unsaved input',async({page})=>{ + await bootPromoFixture(page);await page.locator('#ctmPromoOpen').click(); + await page.locator('#ctmPromoEmail').fill('draft@example.invalid'); + await page.evaluate(()=>{window.promoInput=document.getElementById('ctmPromoEmail');for(let i=0;i<10;i++)CateriumTrialPromoDeveloperV181.bind();}); + expect(await page.evaluate(()=>promoInput===document.getElementById('ctmPromoEmail'))).toBe(true); + await expect(page.locator('#ctmPromoEmail')).toHaveValue('draft@example.invalid'); +});