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