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'); +});