fix(evolution): сохранять черновики и подтверждать операции формы

This commit is contained in:
Denozordec committed 2026-10-03 13:59:24 +07:00
1 parent 7e3cb754b0
commit 118cd31d65
15 files changed
+162 -288

No files matched your search

+6 -6
View File
@@ -2,15 +2,15 @@ import {test,expect,type Page} from "@playwright/test"
import {evoSettings as settings,evoCatalog as catalog} from "./fixtures/evobgp"
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,POST,PUT,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const url=new URL(r.request().url()),fresh=url.port==="8101";return r.fulfill(url.pathname==="/api/auth/config"?{headers,json:{required:false}}:url.pathname==="/api/evobgp/settings"?{headers,json:{...settings,baseUrl:fresh?"https://new.evo.test":settings.baseUrl}}:url.pathname==="/api/evobgp/catalog"?{headers,json:{...catalog,domains:[{...catalog.domains[0],domain:fresh?"fresh.example.test":"stale.example.test"}]}}:{headers,status:503,json:{error:"outside Evolution context"}})})}
for(const action of ["settings","catalog","save"] as const)for(const failed of [false,true])test("Evolution provider: старый backend/"+action+"/"+failed,async({page})=>{
for(const action of ["settings","catalog","save","test"] as const)for(const failed of [false,true])test("Evolution provider: старый backend/"+action+"/"+failed,async({page})=>{
await setup(page);let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
const path=action==="catalog"?"/api/evobgp/catalog":"/api/evobgp/settings"
await page.route("**"+path,async r=>{if(new URL(r.request().url()).port!=="8000"||r.request().method()==="OPTIONS"||(action==="save"?r.request().method()!=="PUT":action==="settings"?r.request().method()!=="GET":false))return r.fallback();started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:action==="settings"?settings:action==="catalog"?{...catalog,domains:[{...catalog.domains[0],domain:"stale.example.test"}]}:{...settings,...r.request().postDataJSON(),ok:true,savedFields:Object.keys(r.request().postDataJSON())}})}catch{}finally{completed=true}})
const path=action==="catalog"?"/api/evobgp/catalog":action==="test"?"/api/evobgp/test":"/api/evobgp/settings"
await page.route("**"+path,async r=>{if(new URL(r.request().url()).port!=="8000"||r.request().method()==="OPTIONS"||(action==="save"?r.request().method()!=="PUT":action==="settings"?r.request().method()!=="GET":false))return r.fallback();started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:action==="test"?{ok:true}:action==="settings"?settings:action==="catalog"?{...catalog,domains:[{...catalog.domains[0],domain:"stale.example.test"}]}:{...settings,...r.request().postDataJSON(),ok:true,savedFields:Object.keys(r.request().postDataJSON())}})}catch{}finally{completed=true}})
try{
await page.goto("/settings")
if(action==="save"){await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue(settings.baseUrl);await page.getByPlaceholder("http://localhost:8080",{exact:true}).fill("https://old-write.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click()}
await expect.poll(()=>started).toBe(true);if(action==="save")await page.getByRole("button",{name:"Общие",exact:true}).click()
if(action==="save"||action==="test"){await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue(settings.baseUrl);if(action==="save"){await page.getByPlaceholder("http://localhost:8080",{exact:true}).fill("https://old-write.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click()}else await page.getByRole("button",{name:"Проверить ключ",exact:true}).click()}
await expect.poll(()=>started).toBe(true);if(action==="save"||action==="test")await page.getByRole("button",{name:"Общие",exact:true}).click()
const url=page.getByPlaceholder("http://localhost:8000",{exact:true});await url.fill("http://localhost:8101");await url.press("Enter");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");release();await expect.poll(()=>completed).toBe(true)
await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("fresh.example.test",{exact:true})).toBeVisible();await expect(page.getByText("stale.example.test",{exact:true})).toHaveCount(0)
await expect(page.getByPlaceholder("http://localhost:8080",{exact:true})).toHaveValue("https://new.evo.test");await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0);await expect(page.getByText("Соединение с EvoBGP установлено",{exact:true})).toHaveCount(0);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("fresh.example.test",{exact:true})).toBeVisible();await expect(page.getByText("stale.example.test",{exact:true})).toHaveCount(0)
}finally{release()}
})
+14
View File
@@ -0,0 +1,14 @@
import {test,expect,type Page} from "@playwright/test"
import {evoSettings as initial,evoCatalog} from "./fixtures/evobgp"
const section=(p:Page)=>p.getByRole("region",{name:"Настройки EvoBGP",exact:true})
const base=(p:Page)=>p.getByRole("textbox",{name:"Базовый URL API",exact:true})
const key=(p:Page)=>p.getByLabel("API-ключ EvoBGP",{exact:true})
async function setup(page:Page){let settings={...initial};await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/evobgp/settings"){if(r.request().method()==="PUT"){const patch=r.request().postDataJSON();settings={baseUrl:patch.baseUrl??settings.baseUrl,enabled:patch.enabled??settings.enabled,secretConfigured:patch.apiKey!==undefined?!!patch.apiKey:settings.secretConfigured};return r.fulfill({json:{...settings,ok:true,savedFields:Object.keys(patch)}})}return r.fulfill({json:settings})}return r.fulfill(path==="/api/evobgp/catalog"?{json:evoCatalog}:path==="/api/evobgp/test"?{json:{ok:true}}:{status:503,json:{error:"outside fixture"}})});await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(base(page)).toHaveValue(initial.baseUrl)}
test("Evolution form: selective URL patch, canonical receipt and no duplicate success",async({page})=>{await setup(page);const patches:unknown[]=[];page.on("request",r=>{if(r.method()==="PUT")patches.push(r.postDataJSON())});await expect(section(page).getByRole("button",{name:"Сохранить",exact:true})).toBeDisabled();await base(page).fill(" new.evo.test/ ");await section(page).getByRole("button",{name:"Сохранить",exact:true}).click();await expect(base(page)).toHaveValue("https://new.evo.test");await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toHaveCount(1);await expect(section(page).getByRole("button",{name:"Сохранить",exact:true})).toBeDisabled();expect(patches).toEqual([{baseUrl:"https://new.evo.test"}])})
test("Evolution form: refresh preserves dirty URL, toggle and replacement key",async({page})=>{await setup(page);await base(page).fill("https://draft.evo.test");await key(page).fill("draft-secret");await page.getByRole("switch",{name:"Использовать каталог EvoBGP"}).click();await page.getByRole("button",{name:"Обновить настройки EvoBGP"}).click();await expect(page.getByText("Обновление настроек EvoBGP…",{exact:true})).toHaveCount(0);await expect(base(page)).toHaveValue("https://draft.evo.test");await expect(key(page)).toHaveValue("draft-secret");await expect(page.getByRole("switch",{name:"Использовать каталог EvoBGP"})).not.toBeChecked();await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(key(page)).toHaveValue("")})
test("Evolution form: invalid URL never writes and test never saves",async({page})=>{await setup(page);let writes=0;page.on("request",r=>{if(r.method()==="PUT")writes++});await base(page).fill("https://evo.test/v1");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(section(page).getByText(/Укажите HTTP\(S\) URL/)).toBeVisible();expect(writes).toBe(0);await base(page).fill("https://draft.evo.test");await page.getByRole("button",{name:"Проверить ключ",exact:true}).click();await expect(page.getByText("Соединение с EvoBGP установлено",{exact:true})).toBeVisible();expect(writes).toBe(0);await expect(page.getByRole("button",{name:"Сохранить",exact:true})).toBeEnabled()})
test("Evolution form: unknown save preserves drafts without retry",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({status:503,json:{error:"save unknown"}})});await key(page).fill("replacement");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(section(page).getByText(/save unknown.*Черновик сохранён/)).toBeVisible();await expect(key(page)).toHaveValue("replacement");await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toHaveCount(0);expect(writes).toBe(1)})
test("Evolution form: key clear requires confirmation and preserves other drafts",async({page})=>{await setup(page);const patches:unknown[]=[];page.on("request",r=>{if(r.method()==="PUT")patches.push(r.postDataJSON())});await base(page).fill("https://draft.evo.test");await key(page).fill("future-key");await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();expect(patches).toEqual([]);await page.getByRole("alertdialog").getByRole("button",{name:"Удалить ключ",exact:true}).click();await expect(page.getByRole("alertdialog")).toHaveCount(0);await expect(page.getByText("Удаление сохранённого ключа подтверждено",{exact:true})).toBeVisible();await expect(base(page)).toHaveValue("https://draft.evo.test");await expect(key(page)).toHaveValue("future-key");expect(patches).toEqual([{apiKey:null}]);await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();expect(patches[1]).toEqual({baseUrl:"https://draft.evo.test",apiKey:"future-key"})})
test("Evolution form: malformed clear retains confirmation and typed key",async({page})=>{await setup(page);await page.route("**/api/evobgp/settings",r=>r.request().method()==="PUT"?r.fulfill({json:{}}):r.fallback());await key(page).fill("future-key");await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить ключ",exact:true}).click();await expect(page.getByRole("alertdialog").getByRole("alert")).toContainText("Подтверждения удаления нет");await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click();await expect(key(page)).toHaveValue("future-key");await expect(page.getByText("Ключ: сохранён",{exact:true})).toBeVisible()})
test("Evolution form: repeated save and other actions are locked during PUT",async({page})=>{await setup(page);let started=false,writes=0,release!:()=>void;const gate=new Promise<void>(r=>release=r);await page.route("**/api/evobgp/settings",async r=>{if(r.request().method()!=="PUT")return r.fallback();started=true;writes++;await gate;try{await r.fulfill({json:{...initial,baseUrl:"https://draft.evo.test",ok:true,savedFields:["baseUrl"]}})}catch{}});try{await base(page).fill("https://draft.evo.test");await page.getByRole("button",{name:"Сохранить",exact:true}).dblclick();await expect.poll(()=>started).toBe(true);await expect(page.getByRole("button",{name:"Сохранение…",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Проверить ключ",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true})).toBeDisabled();await expect(base(page)).toBeDisabled();expect(writes).toBe(1);release();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible()}finally{release()}})
+2 -2
View File
@@ -11,6 +11,6 @@ test("Evolution provider: каталог не задерживает known на
test("Evolution provider: ошибка settings GET сохраняет настройки, каталог и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/settings",r=>r.fulfill(failed?{status:503,json:{error:"settings unavailable"}}:{json:settings}));await open(page);await ready(page);const before=await settingsStamp(page).textContent(),catBefore=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Настройки EvoBGP не обновлены",{exact:true})).toBeVisible();await expect(settingsStamp(page)).toHaveText(before!);await expect(catalogStamp(page)).toHaveText(catBefore!);await expect(base(page)).toHaveValue(settings.baseUrl)})
test("Evolution provider: ошибка каталога сохраняет последний снимок и время",async({page})=>{await setup(page);let failed=false;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog unavailable"}}:{json:catalog}));await open(page);await ready(page);const before=await catalogStamp(page).textContent();failed=true;await page.getByRole("button",{name:"Обновить каталог",exact:true}).click();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveText(before!);await page.locator('a[href="/domains"]').first().click();await expect(page.getByText("kept.example.test",{exact:true})).toBeVisible()})
test("Evolution provider: отключение подтверждённой интеграции очищает каталог",async({page})=>{await setup(page);let enabled=true,posts=0;await page.route("**/api/evobgp/settings",r=>r.fulfill({json:{...settings,enabled}}));await page.route("**/api/evobgp/catalog",r=>{posts++;return r.fulfill({json:catalog})});await open(page);await ready(page);const before=posts;enabled=false;await page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true}).click();await expect(page.getByText("Интеграция: выключена",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP отключён в сохранённых настройках",{exact:true})).toBeVisible();await expect(catalogStamp(page)).toHaveCount(0);expect(posts).toBe(before)})
test("Evolution provider: сохранение не ждёт каталог и не отменяется его отказом",async({page})=>{await setup(page);let failed=false,writes=0;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog after save unavailable"}}:{json:catalog}));await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;failed=true;const patch=r.request().postDataJSON();return r.fulfill({json:{...settings,...patch,ok:true,savedFields:Object.keys(patch)}})});await open(page);await ready(page);await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();expect(writes).toBe(1)})
test("Evolution provider: malformed PUT не подтверждает сохранение",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await ready(page);await base(page).fill("https://new.example.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.locator("#main-content").getByText("Сохранение EvoBGP не подтверждено сервером",{exact:true})).toBeVisible();await expect(base(page)).toHaveValue("https://new.example.test");expect(writes).toBe(1)})
test("Evolution provider: сохранение не ждёт каталог и не отменяется его отказом",async({page})=>{await setup(page);let failed=false,writes=0;await page.route("**/api/evobgp/catalog",r=>r.fulfill(failed?{status:503,json:{error:"catalog after save unavailable"}}:{json:catalog}));await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;failed=true;const patch=r.request().postDataJSON();return r.fulfill({json:{...settings,...patch,ok:true,savedFields:Object.keys(patch)}})});await open(page);await ready(page);await base(page).fill(settings.baseUrl+"/");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.getByText("Настройки EvoBGP сохранены",{exact:true})).toBeVisible();await expect(page.getByText("Каталог EvoBGP не обновлён; показаны последние данные",{exact:true})).toBeVisible();expect(writes).toBe(1)})
test("Evolution provider: malformed PUT не подтверждает сохранение",async({page})=>{await setup(page);let writes=0;await page.route("**/api/evobgp/settings",r=>{if(r.request().method()!=="PUT")return r.fallback();writes++;return r.fulfill({json:{}})});await open(page);await ready(page);await base(page).fill("https://new.example.test");await page.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(page.locator("#main-content").getByText(/Сохранение EvoBGP не подтверждено сервером/)).toBeVisible();await expect(base(page)).toHaveValue("https://new.example.test");expect(writes).toBe(1)})
test("Evolution provider: права settings admin проверяются до GET",async({page})=>{const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:dashboard:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await setup(page);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let reads=0;page.on("request",r=>{if(new URL(r.url()).pathname.startsWith("/api/evobgp/"))reads++});await open(page);await expect(page.getByText("Нет доступа к настройкам EvoBGP",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Обновить настройки EvoBGP",exact:true})).toBeDisabled();expect(reads).toBe(0)})
+13
View File
@@ -0,0 +1,13 @@
import {test,expect} from "@playwright/test"
import {evoSettings,evoCatalog} from "./fixtures/evobgp"
for(const width of [1440,390,320])test("Evolution form: responsive/"+width,async({page})=>{
await page.setViewportSize({width,height:1000})
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:evoSettings}:path==="/api/evobgp/catalog"?{json:evoCatalog}:{status:503,json:{error:"outside fixture"}})})
await page.goto("/settings");await page.getByRole("button",{name:"EvoBGP",exact:true}).click();await expect(page.getByRole("textbox",{name:"Базовый URL API",exact:true})).toHaveValue(evoSettings.baseUrl)
const section=page.getByRole("region",{name:"Настройки EvoBGP",exact:true})
await section.scrollIntoViewIfNeeded();expect(await section.evaluate(e=>e.scrollWidth<=e.clientWidth+2)).toBe(true);expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/evolution-"+width+".png",fullPage:true,animations:"disabled"})
await page.getByRole("button",{name:"Удалить сохранённый ключ",exact:true}).click();await expect(page.getByRole("alertdialog")).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/evolution-clear-"+width+".png",fullPage:true,animations:"disabled"})
})