import {test,expect} from '@playwright/test'; import fs from 'node:fs'; import {trainingInit} from './training-catalog-fixture.mjs'; async function blank(page){ await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'
'})); await page.goto('/index.html'); } async function syncHarness(page){ await blank(page);await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'}); const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js')); source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={ testInit:(c,permissions,role='manager')=>{client=c;session={user:{id:'employee',email:'employee@example.invalid'}};workspace={id:'company',name:'Компания',role,permissions,is_active:true};config.migrated.company=true;config.tenantStorageReady=true;config.localWorkspaceId='company';config.workspaceId='company'}, testBaseline:setBaseline,testMerge:mergePayloads,testShouldSync:shouldSyncKey,testApply:applyPayload,`); await page.addScriptTag({content:'var orders=[],boxes=[];'+source}); } for(const hasBaseline of [false,true])test(`employee with an empty device pulls company catalog and orders without a write, baseline=${hasBaseline}`,async({page})=>{ await syncHarness(page); const result=await page.evaluate(async hasBaseline=>{ const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:[{id:'real-box',name:'Рабочий бокс',price:1200}]},sunOrders:{t:'j',v:[{id:'real-order',event:'Рабочий заказ',total:2400}]},sunCompanyProfileV1:{t:'j',v:{name:'Рабочая компания'}},sunReadNotificationsV1:{t:'j',v:['owner-read']}}}; const calls=[];window.toasts=[];window.SunEnterprise={toast:(s,t)=>toasts.push({s,t})}; SunCloudV2.testInit({rpc:async(name,args)=>{calls.push(name);if(name==='sun_fetch_app_state')return {data:[{payload,revision:7}]};throw new Error('Unexpected write '+name)}},{'app.read':true,'orders.view':true,'orders.create':true,'orders.edit':true,'orders.delete':false,'catalog.view':true,'catalog.edit':false,'settings.edit':false}); localStorage.setItem('sunBoxes','[]');localStorage.setItem('sunOrders','[]');localStorage.setItem('sunReadNotificationsV1','["employee-read"]');localStorage.setItem('sunPrintSettingsV1','{"localDefault":true}'); if(hasBaseline)await SunCloudV2.testBaseline({payload,revision:7}); await SunCloudV2.syncNow({quiet:true}); return {calls,toasts,status:SunCloudV2.status().lastStatus,orders:JSON.parse(localStorage.sunOrders),boxes:JSON.parse(localStorage.sunBoxes),runtimeOrderIds:orders.map(o=>o.id),runtimeBoxIds:boxes.map(b=>b.id),profile:JSON.parse(localStorage.sunCompanyProfileV1)}; },hasBaseline); expect(result.status).toBe('ready');expect(result.calls).toEqual(['sun_fetch_app_state']);expect(result.toasts.filter(t=>t.t==='error')).toEqual([]); expect(result.orders.map(o=>o.id)).toEqual(['real-order']);expect(result.boxes.map(o=>o.id)).toEqual(['real-box']);expect(result.runtimeOrderIds).toEqual(['real-order']);expect(result.runtimeBoxIds).toEqual(['real-box']);expect(result.profile.name).toBe('Рабочая компания'); }); test('an allowed employee order edit does not change notifications or read-only sections',async({page})=>{ await syncHarness(page); const r=await page.evaluate(async()=>{ const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:[{id:'box',name:'Рабочий бокс',price:500}]},sunOrders:{t:'j',v:[{id:1,total:500}]},sunReadNotificationsV1:{t:'j',v:['owner-only']},sunCompanyProfileV1:{t:'j',v:{name:'Компания'}}}}; let row={payload,revision:1};const writes=[];window.toasts=[];window.SunEnterprise={toast:(s,t)=>toasts.push({s,t})}; SunCloudV2.testInit({rpc:async(name,args)=>{if(name==='sun_fetch_app_state')return {data:[structuredClone(row)]};if(name==='sun_save_app_state_v17'){ for(const k of new Set([...Object.keys(payload.storage),...Object.keys(args.p_payload.storage)]))if(k!=='sunOrders'&&JSON.stringify(payload.storage[k])!==JSON.stringify(args.p_payload.storage[k]))return {error:{message:'Нет права изменять раздел: '+k}}; writes.push(structuredClone(args.p_payload));row={payload:structuredClone(args.p_payload),revision:2};return {data:[row]}; }throw new Error(name)}},{'app.read':true,'orders.view':true,'orders.edit':true,'catalog.view':true,'catalog.edit':false,'settings.edit':false}); await SunCloudV2.testApply(payload,true);await SunCloudV2.testBaseline(row); localStorage.setItem('sunOrders','[{"id":1,"total":700}]');localStorage.setItem('sunReadNotificationsV1','["employee-only"]');localStorage.setItem('sunPrintSettingsV1','{"bootstrap":true}'); await SunCloudV2.syncNow({quiet:true});return {writes,toasts,status:SunCloudV2.status().lastStatus}; }); expect(r.status).toBe('ready');expect(r.writes).toHaveLength(1);expect(r.writes[0].storage.sunOrders.v).toEqual([{id:1,total:700}]);expect(r.writes[0].storage.sunReadNotificationsV1.v).toEqual(['owner-only']);expect(r.writes[0].storage.sunPrintSettingsV1).toBeUndefined();expect(r.toasts.filter(t=>t.t==='error')).toEqual([]); }); test('order reconciliation preserves records without delete rights and does not grant create/edit rights',async({page})=>{ await syncHarness(page); const r=await page.evaluate(()=>{ const p=rows=>({format:'sun-cloud-v2',version:2,storage:{sunOrders:{t:'j',v:rows}}}),base=p([{id:1,total:500},{id:2,total:300}]),local=p([{id:1,total:700},{id:3,total:900}]); const merge=permissions=>{SunCloudV2.testInit({},permissions);return SunCloudV2.testMerge(base,local,base).payload.storage.sunOrders.v}; return {editor:merge({'orders.edit':true}),creator:merge({'orders.create':true}),deleter:merge({'orders.delete':true}),all:merge({'orders.create':true,'orders.edit':true,'orders.delete':true})}; }); expect(r.editor).toEqual([{id:1,total:700},{id:2,total:300}]);expect(r.creator).toEqual([{id:1,total:500},{id:3,total:900},{id:2,total:300}]);expect(r.deleter).toEqual([{id:1,total:500}]);expect(r.all).toEqual([{id:1,total:700},{id:3,total:900}]); }); test('notification read flags are personal per user and company and absent from cloud payload',async({page})=>{ await syncHarness(page); const r=await page.evaluate(async()=>{ SunCloudV2.testInit({},{});const own=SunSafe.personalStorageKey('notifications-read');localStorage.setItem(own,'["n1"]');localStorage.setItem('sunReadNotificationsV1','["old-shared"]'); const getSession=SunCloudV2.getSession,getWorkspace=SunCloudV2.getWorkspace; SunCloudV2.getSession=()=>({user:{id:'another-employee'}});const another=SunSafe.personalStorageKey('notifications-read'); SunCloudV2.getSession=getSession;SunCloudV2.getWorkspace=()=>({id:'another-company'});const otherCompany=SunSafe.personalStorageKey('notifications-read'); SunCloudV2.getSession=()=>null;const signedOut=SunSafe.personalStorageKey('notifications-read');SunCloudV2.getSession=getSession;SunCloudV2.getWorkspace=getWorkspace; return {own,another,otherCompany,signedOut,otherRead:localStorage.getItem(another),payload:await SunCloudV2.snapshotLocal(),oldSync:SunCloudV2.testShouldSync('sunReadNotificationsV1')}; }); expect(new Set([r.own,r.another,r.otherCompany]).size).toBe(3);expect(r.signedOut).toBe('');expect(r.otherRead).toBeNull();expect(r.oldSync).toBe(false);expect(Object.keys(r.payload.storage).some(k=>/Notifications|personal/.test(k))).toBe(false); const html=fs.readFileSync('public/index.html','utf8');expect(html).toContain("personalStorageKey('notifications-read')");expect(html).not.toContain("const readKey='sunReadNotificationsV1'"); }); test('employee sidebar follows verified workspace, resets during a switch and ignores late replies',async({page})=>{ await blank(page); await page.evaluate(()=>{ window.selected='solnce-company';window.brandCalls=[];window.late=null; window.SunCloudV2={getSession:()=>({user:{id:'employee'}}),getWorkspace:()=>({id:selected}),getClient:()=>({rpc:async(name,args)=>{brandCalls.push({name,args});if(args?.p_workspace==='pending-company')return new Promise(resolve=>late=resolve);return {data:{variant:args?.p_workspace==='solnce-company'?'solnce':'caterium'}}}})}; }); await page.addScriptTag({url:'/core/company-branding.js'});await expect(page.locator('header .brand')).toContainText('Солнце Кейтеринг'); await page.evaluate(()=>{selected='pending-company';window.dispatchEvent(new Event('sun:cloud-tenant-changing'));CateriumBranding.refreshSidebar()}); await expect(page.locator('header .brand')).toContainText('Caterium'); await page.evaluate(async()=>{selected='ordinary-company';await CateriumBranding.refreshSidebar();late({data:{variant:'solnce'}})}); await expect(page.locator('header .brand')).toContainText('Caterium'); expect(await page.evaluate(()=>brandCalls.every(c=>c.name==='caterium_workspace_sidebar_brand'&&c.args.p_workspace))).toBe(true); }); test('older server fallback does not fabricate employee company branding',async({page})=>{ await blank(page);await page.evaluate(()=>{window.calls=[];window.SunCloudV2={getSession:()=>({user:{id:'employee'}}),getWorkspace:()=>({id:'company'}),getClient:()=>({rpc:async name=>{calls.push(name);return name==='caterium_workspace_sidebar_brand'?{error:{code:'PGRST202'}}:{data:{variant:'caterium'}}}})}}); await page.addScriptTag({url:'/core/company-branding.js'});await expect.poll(()=>page.evaluate(()=>calls.length)).toBe(2);await expect(page.locator('header .brand')).toContainText('Caterium'); }); test('mobile header has visible profile/logout; failed or pending account details never hide exit',async({page},info)=>{ test.setTimeout(60000);await page.setViewportSize({width:390,height:844}); await page.addInitScript(trainingInit);await page.route('https://**/*',r=>r.abort()); await page.goto('/index.html',{waitUntil:'domcontentloaded'});await page.waitForFunction(()=>window.SunCloudV2?.getWorkspace()?.id&&window.CateriumAccountCenterV1780); await expect(page.locator('#sunCloudAuthGateV3')).toHaveCount(0,{timeout:20000}); const profile=page.locator('#cateriumMobileAccountButton'),logout=page.locator('#cateriumMobileLogout'); await expect(profile).toBeVisible();await expect(logout).toBeVisible();await expect(logout).toHaveText('Выйти'); const box=await logout.boundingBox();expect(box.height).toBeGreaterThanOrEqual(44);expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(391); await page.evaluate(()=>{window.logoutCalls=0;window.SunCloudV2.signOut=async()=>{logoutCalls++};const c=SunCloudV2.getClient(),rpc=c.rpc;c.rpc=(name,args)=>name==='caterium_account_snapshot'?new Promise(resolve=>window.resolveProfile=resolve):rpc(name,args)}); await page.screenshot({path:`test-results/employee-mobile-header-${info.project.name}.png`}); await profile.click();await expect(page.locator('#cacHeaderLogout')).toBeVisible();await expect(page.locator('#cacLogout')).toBeAttached(); await page.locator('#cacHeaderLogout').click();expect(await page.evaluate(()=>logoutCalls)).toBe(1); await page.evaluate(()=>resolveProfile({error:{message:'Offline'}}));await expect(page.locator('#cateriumAccountCenterBody')).toContainText('Выход из аккаунта доступен'); await expect(page.locator('#cacHeaderLogout')).toBeVisible();await page.screenshot({path:`test-results/employee-profile-error-${info.project.name}.png`}); await page.locator('#cateriumAccountCenter .cac-close').click();await logout.click();expect(await page.evaluate(()=>logoutCalls)).toBe(2); const overflow=await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1);expect(overflow).toBe(false); }); test('late profile details cannot reappear after changing account or workspace',async({page})=>{ await blank(page);await page.evaluate(()=>{window.user='a';window.ws='company-a';window.pending=[];window.SunCloudV2={getSession:()=>({user:{id:user,email:user+'@example.invalid'}}),getWorkspace:()=>({id:ws,name:ws,role:'manager'}),getClient:()=>({rpc:()=>new Promise(resolve=>pending.push(resolve))})}}); await page.addScriptTag({url:'/core/sun-safe.js'});await page.waitForFunction(()=>window.CateriumAccountCenterV1780); await page.evaluate(()=>{CateriumAccountCenterV1780.open()});await expect(page.locator('#cateriumAccountCenter')).toHaveClass('on'); await page.evaluate(()=>{user='b';ws='company-b';window.dispatchEvent(new Event('sun:cloud-tenant-changing'));pending[0]({data:{company_name:'PRIVATE OLD COMPANY',is_owner:true}})}); await expect(page.locator('#cateriumAccountCenter')).not.toHaveClass('on');await page.evaluate(()=>{CateriumAccountCenterV1780.open()}); await expect(page.locator('#cateriumAccountCenterBody')).toContainText('company-b');await expect(page.locator('#cateriumAccountCenterBody')).not.toContainText('PRIVATE OLD COMPANY'); });