From b109532f3316f6eb597e826353d2bf72d8a13a9e Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 19:21:19 +0700 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B4=D0=B8=D1=82=D1=8C=20=D1=80=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=B0=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/alerts/page.tsx | 2 +- components/alerts-outbox-panel.tsx | 38 +++++++++++++++++++++++------- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 11 ++++++++- docs/ux-ui-stage-11b.md | 4 ++++ lib/alerts-outbox-checkpoint.ts | 6 +++++ shared/api/alerts-outbox.test.ts | 9 ++++++- shared/api/alerts-outbox.ts | 11 ++++++++- tests/e2e/alerts-context.spec.ts | 9 ++++++- tests/e2e/alerts-outbox.spec.ts | 25 ++++++++++++++++++-- tests/e2e/fixtures/alerts.ts | 2 ++ 11 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 lib/alerts-outbox-checkpoint.ts diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 6ae151d3..1cf54ab5 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -2562,7 +2562,7 @@ function AlertsContent() { {config.busy?

Ожидаем подтверждение правил и групп…

:null} {config.unknown&&!config.busy?

Результат сохранения правил неизвестен. Запрос мог выполниться; проверьте правила и группы в БД перед повтором. Обновление списка не разрешает повтор автоматически.

:null} {isLive?:null} - {isLive?:null} + {isLive?:null} {known?<> diff --git a/components/alerts-outbox-panel.tsx b/components/alerts-outbox-panel.tsx index 1eb8cc72..07e408ca 100644 --- a/components/alerts-outbox-panel.tsx +++ b/components/alerts-outbox-panel.tsx @@ -1,11 +1,19 @@ "use client" -import {useCallback,useEffect,useRef,useState} from "react" +import {useCallback,useEffect,useRef,useState,useSyncExternalStore} from "react" import type {AlertsOutboxOverview} from "@mmapp/contracts/alerts" import {can} from "@/lib/auth" -import {getAlertsOutbox} from "@/shared/api/alerts-outbox" +import {getAlertsOutbox,retryAlertsOutbox} from "@/shared/api/alerts-outbox" import {OpsPanel} from "@/components/ops-panel" import {Button} from "@/components/ui/button" -export function AlertsOutboxPanel({base}:{base:string}){ +import {ApiClientError} from "@/shared/api/http-client" +import {outboxPending,subscribeOutboxPending,setOutboxPending} from "@/lib/alerts-outbox-checkpoint" +import {AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter} from "@/components/ui/alert-dialog" +export function AlertsOutboxPanel({base,operationsBusy,available,beginOperation,endOperation}:{base:string;operationsBusy:boolean;available:boolean;beginOperation:()=>boolean;endOperation:()=>void}){ + const source=JSON.stringify(["live",base]),unknown=useSyncExternalStore(subscribeOutboxPending,()=>outboxPending(source),()=>false) + const [selected,setSelected]=useState(null),[ack,setAck]=useState(false),[reviewed,setReviewed]=useState(false),[mutationBusy,setMutationBusy]=useState(false),[mutationError,setMutationError]=useState(null),[message,setMessage]=useState(null) + const mutationLock=useRef(false),writeController=useRef(null),writeRevision=useRef(0) + const writable=can("mm:alerts:write")&&available&&!operationsBusy&&!unknown&&!mutationBusy + const [data,setData]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(null),[receivedAt,setReceivedAt]=useState(null) const active=useRef(true),revision=useRef(0),controller=useRef(null) const refresh=useCallback(async()=>{ @@ -15,15 +23,29 @@ export function AlertsOutboxPanel({base}:{base:string}){ try{const result=await getAlertsOutbox(base,{signal:controller.current.signal});if(current()){setData(result);setReceivedAt(new Date().toISOString())}}catch(e){if(current())setError(e instanceof Error?e.message:"Не удалось получить очередь")} finally{if(current())setBusy(false)} },[base]) - useEffect(()=>{const generation=revision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;controller.current?.abort()}},[refresh]) - return
void refresh()}>Обновить} contentClassName="space-y-3 px-5 py-4"> + useEffect(()=>{const generation=revision,writeGeneration=writeRevision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;writeGeneration.current++;controller.current?.abort();writeController.current?.abort()}},[refresh]) + async function retry(){ + if(!selected||!ack||!writable||mutationLock.current||!beginOperation())return + mutationLock.current=true;setMutationBusy(true);setMutationError(null);setMessage(null);controller.current?.abort();revision.current++;setBusy(false) + const requestRevision=++writeRevision.current,abort=new AbortController();writeController.current=abort;setOutboxPending(source,true) + const current=()=>active.current&&requestRevision===writeRevision.current + try{await retryAlertsOutbox(base,selected.id,{expectedVersion:selected.resolutionVersion,acknowledgedDuplicateRisk:true},{signal:abort.signal});if(current()){setOutboxPending(source,false);setSelected(null);setMessage("Сообщение поставлено в очередь. Отправка и доставка ещё не подтверждены.");void refresh()}} + catch(e){if(current()){if(e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status))setOutboxPending(source,false);setMutationError(e instanceof Error?e.message:"Результат повтора неизвестен")}} + finally{mutationLock.current=false;endOperation();if(current())setMutationBusy(false)} + } + return
void refresh()}>Обновить} contentClassName="space-y-3 px-5 py-4"> {error?

{error}{data?". Показан последний успешный снимок очереди.":""}

:null}

{busy?"Обновление очереди…":data?"Последний успешный ответ: "+new Date(receivedAt!).toLocaleString("ru-RU"):"Данные очереди ещё не получены."}

+ {message?

{message}

:null} + {mutationError?

{mutationError}

:null} + {unknown?

{mutationBusy?"Постановка в очередь выполняется…":"Результат повтора неизвестен. Автоматический повтор заблокирован. Проверьте очередь и чат: сообщение могло быть отправлено."}

:null} {data?<>

Ожидают: {data.counts.pending} · API принял: {data.counts.sent} · Завершены с ошибкой: {data.counts.failed}

{data.counts.unknown||data.counts.processing||data.counts.unrecognized?

Нужна проверка: неизвестный результат — {data.counts.unknown}, в обработке — {data.counts.processing}, нераспознанный статус — {data.counts.unrecognized}. Эти записи автоматически не переотправляются. Обработка может означать текущий запрос или прерванный процесс отправки; проверьте чат и журнал перед повтором.

:null} -

Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повторная отправка из этого экрана пока недоступна.

- {data.items.length?
    {data.items.map(item=>
  • {item.ruleName||item.id}

    {item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}

  • )}
:null} +

Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повтор разрешён только для неизвестного результата после проверки чата.

+ {data.items.length?
    {data.items.map(item=>
  • {item.ruleName||item.id}

    {item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}

    {item.status==="unknown"?:null}
  • )}
:null} :null} -
+
+ {if(!open&&!mutationLock.current)setSelected(null)}}>Повторить сообщение?{selected?.ruleName||selected?.id}. Предыдущая попытка могла доставить сообщение. Повтор может создать дубликат; повтор использует сохранённое сообщение и настройки подключения Telegram на момент новой попытки.{mutationError?

{mutationError}

:null}
+
} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 932d3b88..7ead2f4c 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.4; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.5: подключить UI ручного повтора unknown с подтверждением риска дубликата, общим checkpoint при неизвестном POST и сохранением формы/источника; затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. +Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.5; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.6: исправить права общих sidebar/dashboard readers (источник фонового GRE оказался AppSidebar), затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing остаётся без повтора до отдельного сценария подтверждённой остановки отправителя. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index d81f3082..6a2d9634 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.4; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.5; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11Б.4.4.3.5 — явный повтор очереди в UI (2026-10-03) + +- Unknown item получает доступный по правам/health action и существующий AlertDialog: явное подтверждение проверки чата и риска дубликата. Processing не имеет action. POST передаёт только id/version/risk; строгий helper проверяет pending receipt и version+1 без retry. UI сообщает постановку в очередь, а не доставку. +- Синхронный lock и общий Alerts operation lock исключают concurrent config/test/refresh; dialog не закрывается во время POST. До запроса ставится source-scoped memory/session checkpoint без payload/secret; malformed/network/5xx сохраняет его через refresh/reload/ABA. Подтверждённый 4xx снимает checkpoint и сохраняет форму; ручная проверка результата отдельная и не отправляет POST. +- API 2/2, queue browser 6/6 плюс mobile dialog 1/1 (320px screenshot просмотрен, Escape/focus/cancel без POST), context 14/14 прошли. Typecheck без incremental, профильный lint прошли; ReUI Frame checklist применён, installed primitives не менялись. +- Реальный PG/Telegram не проверен. Полная Alerts приёмка не завершена: processing recovery требует отдельного сценария остановки отправителя, остались pickers/адаптивность и неподдерживаемое BGP-prefix условие. Backend manual retry не подтверждает внешнюю доставку. +- Уточнение расследования прав: глобальный GRE GET создаёт AppSidebar, а useDashboardLive вызывается только dashboard page. Ранее запись о «глобальном dashboard reader» была неточной; оба reader требуют проверки прав. +- Следующий точный шаг 11Б.4.4.3.6: исправить права общих sidebar/dashboard readers (источник фонового GRE оказался AppSidebar), затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing остаётся без повтора до отдельного сценария подтверждённой остановки отправителя. + ## Блок 11Б.4.4.3.4 — атомарный ручной повтор unknown (2026-10-03) - POST /api/alerts/outbox/:id/retry под mm:alerts:write/admin требует literal acknowledgedDuplicateRisk:true и expectedVersion. Conditional UPDATE только telegram+unknown+resolution_version переводит запись в pending и увеличивает version. Processing/sent/failed/pending, устаревший снимок и повторный запрос получают 409; endpoint не отправляет Telegram и не пишет ложный успешный history. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 75db77b0..1225be21 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -296,3 +296,7 @@ GET/UI очереди принят локально: независимый stri ### Продолжение 2026-10-03 — 11Б.4.4.3.4 Backend manual unknown retry принят локально: acknowledged risk + atomic version CAS, transaction receipt, no send/history; DI/HTTP/JWT/build/typecheck/lint/queue browser прошли. Реальный PG/0009 и UI действия не приняты. Следующий блок 11Б.4.4.3.5: подключить UI ручного повтора unknown с подтверждением риска дубликата, общим checkpoint при неизвестном POST и сохранением формы/источника; затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. + +### Продолжение 2026-10-03 — 11Б.4.4.3.5 + +UI manual unknown retry принят локально: explicit duplicate risk, common lock, strict pending receipt, unknown checkpoint across reload/ABA. API 2, browser 6 + mobile 1, context14, typecheck/lint прошли. Processing recovery и внешняя доставка не приняты. Источник global GRE — AppSidebar, dashboard hook только на dashboard page. Следующий блок 11Б.4.4.3.6: исправить права общих sidebar/dashboard readers (источник фонового GRE оказался AppSidebar), затем адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Processing остаётся без повтора до отдельного сценария подтверждённой остановки отправителя. diff --git a/lib/alerts-outbox-checkpoint.ts b/lib/alerts-outbox-checkpoint.ts new file mode 100644 index 00000000..2e52ed80 --- /dev/null +++ b/lib/alerts-outbox-checkpoint.ts @@ -0,0 +1,6 @@ +// Persists ambiguous manual queue retry per source; never stores payload or credentials. +const pending=new Set(),listeners=new Set<()=>void>() +const key=(source:string)=>"mm:alerts-outbox-pending:"+source +export function outboxPending(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}} +export function subscribeOutboxPending(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener)}} +export function setOutboxPending(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* Memory guard survives section navigation. */}for(const listener of listeners)listener()} diff --git a/shared/api/alerts-outbox.test.ts b/shared/api/alerts-outbox.test.ts index 3278db7c..eabc3073 100644 --- a/shared/api/alerts-outbox.test.ts +++ b/shared/api/alerts-outbox.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import {test} from "node:test" -import {getAlertsOutbox} from "./alerts-outbox" +import {getAlertsOutbox,retryAlertsOutbox} from "./alerts-outbox" const empty={counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20} test("queue strict no-store reader distinguishes confirmed zero from malformed or missing held data",async()=>{ const original=globalThis.fetch;try{ @@ -10,3 +10,10 @@ test("queue strict no-store reader distinguishes confirmed zero from malformed o const late=new AbortController();globalThis.fetch=async()=>{late.abort();return Response.json(empty)};await assert.rejects(getAlertsOutbox("https://backend.test",{signal:late.signal})) }finally{globalThis.fetch=original} }) + +test("queue retry requires risk acknowledgement and strict matching receipt, one POST with no retry",async()=>{ + const original=globalThis.fetch;try{let calls=0;globalThis.fetch=async(url,init)=>{calls++;assert.ok(String(url).endsWith("/api/alerts/outbox/a%2Fb/retry"));assert.equal(init?.method,"POST");assert.deepEqual(JSON.parse(String(init?.body)),{expectedVersion:3,acknowledgedDuplicateRisk:true});return Response.json({ok:true,id:"a/b",status:"pending",resolutionVersion:4})} + await assert.rejects(retryAlertsOutbox("https://backend.test","a/b",{expectedVersion:3,acknowledgedDuplicateRisk:false}));assert.equal(calls,0);assert.equal((await retryAlertsOutbox("https://backend.test","a/b",{expectedVersion:3,acknowledgedDuplicateRisk:true})).status,"pending");assert.equal(calls,1) + for(const raw of [{},{ok:true,id:"other",status:"pending",resolutionVersion:4},{ok:true,id:"a/b",status:"sent",resolutionVersion:4},{ok:true,id:"a/b",status:"pending",resolutionVersion:3}]){calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(retryAlertsOutbox("https://backend.test","a/b",{expectedVersion:3,acknowledgedDuplicateRisk:true}),/не подтверждена/);assert.equal(calls,1)} + }finally{globalThis.fetch=original} +}) diff --git a/shared/api/alerts-outbox.ts b/shared/api/alerts-outbox.ts index 9c9161bc..b5160968 100644 --- a/shared/api/alerts-outbox.ts +++ b/shared/api/alerts-outbox.ts @@ -1,4 +1,4 @@ -import {alertsOutboxOverviewSchema} from "@mmapp/contracts/alerts" +import {alertsOutboxOverviewSchema,alertsOutboxRetrySchema,alertsOutboxRetryReceiptSchema} from "@mmapp/contracts/alerts" import {requestJson} from "./http-client" export async function getAlertsOutbox(base:string,options?:{signal?:AbortSignal}){ const signal=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);signal.throwIfAborted() @@ -6,3 +6,12 @@ export async function getAlertsOutbox(base:string,options?:{signal?:AbortSignal} if(!parsed.success)throw Error("Состояние очереди не подтверждено сервером") return parsed.data } + +export async function retryAlertsOutbox(base:string,id:string,input:unknown,options?:{signal?:AbortSignal}){ + const body=alertsOutboxRetrySchema.safeParse(input);if(!body.success||!id.trim()||id.length>200)throw Error("Проверьте подтверждение и версию сообщения") + const signal=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);signal.throwIfAborted() + const raw=await requestJson(base,"/api/alerts/outbox/"+encodeURIComponent(id)+"/retry",{method:"POST",body:JSON.stringify(body.data),signal,cache:"no-store"});signal.throwIfAborted() + const parsed=alertsOutboxRetryReceiptSchema.safeParse(raw) + if(!parsed.success||parsed.data.id!==id||parsed.data.resolutionVersion!==body.data.expectedVersion+1)throw Error("Постановка сообщения в очередь не подтверждена сервером") + return parsed.data +} diff --git a/tests/e2e/alerts-context.spec.ts b/tests/e2e/alerts-context.spec.ts index adb9d374..175bc156 100644 --- a/tests/e2e/alerts-context.spec.ts +++ b/tests/e2e/alerts-context.spec.ts @@ -1,6 +1,6 @@ import {emptyOutbox} from "./fixtures/alerts" import {test,expect,type Page} from "@playwright/test" -import {emptyAlerts,alertsWithRule} from "./fixtures/alerts" +import {emptyAlerts,alertsWithRule,heldOutbox} from "./fixtures/alerts" const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,PUT,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization,If-Match"} async function source(page:Page,value:string){const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill(value);await input.press("Enter");await expect(input).toHaveValue(value);await expect(page.getByText("Доступен",{exact:true})).toBeVisible()} for(const failed of [false,true])test("Alerts source: ABA discards old primary and target outcomes/"+failed,async({page})=>{ @@ -48,3 +48,10 @@ for(const failed of [false,true])test("Queue source: ABA ignores old held list o return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) try{await page.goto("/alerts");await expect.poll(()=>started).toBeGreaterThan(0);await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await page.goto("/settings");await source(page,"http://localhost:8101");old=false;await source(page,"http://localhost:8000");await page.goto("/alerts");const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Ожидают: 0");release();await expect.poll(()=>completed).toBe(started);await expect(panel).not.toContainText("OLD QUEUE");await expect(panel).not.toContainText("OLD HELD");await expect(panel).toContainText("Ожидают: 0")}finally{release()} }) + +for(const failed of [false,true])test("Queue retry source: ABA retains unknown checkpoint and ignores late receipt/"+failed,async({page})=>{ + await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?heldOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + await page.route("http://localhost:8000/api/alerts/outbox/held-fixture/retry",async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD RETRY FAILED"}}:{headers,json:{ok:true,id:"held-fixture",status:"pending",resolutionVersion:4}})}catch{}finally{completed=true}}) + try{await page.goto("/alerts");await page.getByRole("button",{name:"Повторить сообщение Held retry fixture"}).click();const dialog=page.getByRole("alertdialog");await dialog.getByRole("checkbox",{name:"Проверил чат и принимаю риск дубликата"}).check();await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Результат повтора неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(panel.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeDisabled();await expect(panel).not.toContainText("Сообщение поставлено в очередь");await expect(panel).not.toContainText("OLD RETRY FAILED");expect(calls).toBe(1)}finally{release()} +}) diff --git a/tests/e2e/alerts-outbox.spec.ts b/tests/e2e/alerts-outbox.spec.ts index fc70dfd9..8ea2d120 100644 --- a/tests/e2e/alerts-outbox.spec.ts +++ b/tests/e2e/alerts-outbox.spec.ts @@ -1,5 +1,5 @@ -import {test,expect,type Page} from "@playwright/test" -import {emptyAlerts,emptyOutbox} from "./fixtures/alerts" +import {test,expect,type Page,type Route} from "@playwright/test" +import {emptyAlerts,emptyOutbox,heldOutbox} from "./fixtures/alerts" async function setup(page:Page,reply:()=>{status?:number;json:unknown}){ await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/alerts/outbox"?reply():{json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts") } @@ -9,3 +9,24 @@ test("Queue initial failure is unknown, retry confirms zero",async({page})=>{ test("Queue held states retain confirmed snapshot after failure and fit mobile",async({page})=>{ await page.setViewportSize({width:320,height:1000});let fail=false;const held={...emptyOutbox,counts:{...emptyOutbox.counts,total:4,pending:1,processing:1,unknown:1,unrecognized:1},items:[{id:"a",resolutionVersion:0,status:"unknown",ruleName:"Held fixture",createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T01:00:00Z"},{id:"b",resolutionVersion:0,status:"processing",ruleName:null,createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T02:00:00Z"}]};await setup(page,()=>fail?{status:503,json:{error:"QUEUE UNAVAILABLE"}}:{json:held});const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Held fixture");await expect(panel).toContainText("автоматически не переотправляются");fail=true;await panel.getByRole("button",{name:"Обновить очередь Telegram"}).click();await expect(panel.getByRole("alert")).toContainText("Показан последний успешный снимок");await expect(panel).toContainText("Ожидают: 1");const bounds=await panel.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(320);await panel.screenshot({path:"test-results/alerts-outbox-320.png"}) }) + +async function retrySetup(page:Page,write:(r:Route)=>Promise,required=false){ + if(required)await page.addInitScript(()=>localStorage.setItem("mmapp_token","fixture."+btoa(JSON.stringify({sub:"fixture",exp:Math.floor(Date.now()/1000)+3600,apps:["mm"],permissions:["mm:alerts:read"]}))+".fixture")) + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path.endsWith("/retry"))return write(r);return r.fulfill({json:path==="/api/auth/config"?{required}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?heldOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts") +} +async function openRetry(page:Page){await page.getByRole("button",{name:"Повторить сообщение Held retry fixture"}).click();const dialog=page.getByRole("alertdialog");await expect(dialog.getByRole("button",{name:"Поставить в очередь повторно"})).toBeDisabled();await dialog.getByRole("checkbox",{name:"Проверил чат и принимаю риск дубликата"}).check();return dialog} +test("Manual queue retry: explicit risk, one pending POST excludes concurrent operations and confirms only queue receipt",async({page})=>{ + let calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve);await retrySetup(page,async r=>{calls++;expect(r.request().postDataJSON()).toEqual({expectedVersion:3,acknowledgedDuplicateRisk:true});await gate;await r.fulfill({json:{ok:true,id:"held-fixture",status:"pending",resolutionVersion:4}})}); + try{const dialog=await openRetry(page);await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect.poll(()=>calls).toBe(1);await expect(dialog.getByRole("button",{name:"Отмена"})).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true,includeHidden:true})).toBeDisabled();release();await expect(dialog).toHaveCount(0);await expect(page.getByText("Сообщение поставлено в очередь. Отправка и доставка ещё не подтверждены.")).toBeVisible();expect(calls).toBe(1)}finally{release()} +}) +test("Manual queue retry: malformed receipt retains dialog and checkpoint across refresh/reload until explicit review",async({page})=>{ + let calls=0;await retrySetup(page,async r=>{calls++;return r.fulfill({json:{ok:true}})});const dialog=await openRetry(page);await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждена");await expect(dialog.getByRole("button",{name:"Поставить в очередь повторно"})).toBeDisabled();await dialog.getByRole("button",{name:"Отмена"}).click();await page.getByRole("button",{name:"Обновить очередь Telegram"}).click();await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeDisabled();await page.reload();const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Результат повтора неизвестен");await expect(panel.getByRole("button",{name:"Результат повтора проверен"})).toBeDisabled();await panel.getByRole("checkbox",{name:"Проверил очередь и чат"}).check();await panel.getByRole("button",{name:"Результат повтора проверен"}).click();await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeEnabled();expect(calls).toBe(1) +}) +test("Manual queue retry: read permission cannot enqueue",async({page})=>{ + let calls=0;await retrySetup(page,async r=>{calls++;return r.fulfill({status:409,json:{error:"Fixture queue conflict"}})},true);await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeDisabled();expect(calls).toBe(0) +}) +test("Manual queue retry: confirmed conflict keeps form without unknown checkpoint",async({page})=>{ + await retrySetup(page,r=>r.fulfill({status:409,json:{error:"Fixture queue conflict"}}));const dialog=await openRetry(page);await dialog.getByRole("button",{name:"Поставить в очередь повторно"}).click();await expect(dialog.getByRole("alert")).toHaveText("Fixture queue conflict");await expect(dialog.getByRole("checkbox")).toBeChecked();await expect(dialog.getByRole("button",{name:"Поставить в очередь повторно"})).toBeEnabled() +}) + +test("Manual queue retry: mobile confirmation fits, cancel restores focus without POST",async({page})=>{await page.setViewportSize({width:320,height:1000});let calls=0;await retrySetup(page,r=>{calls++;return r.fulfill({json:{ok:true}})});const dialog=await openRetry(page);const bounds=await dialog.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(320);await dialog.screenshot({path:"test-results/alerts-outbox-dialog-320.png"});await page.keyboard.press("Escape");await expect(dialog).toHaveCount(0);await expect(page.getByRole("button",{name:"Повторить сообщение Held retry fixture"})).toBeFocused();expect(calls).toBe(0)}) diff --git a/tests/e2e/fixtures/alerts.ts b/tests/e2e/fixtures/alerts.ts index b647fa2d..4667dc81 100644 --- a/tests/e2e/fixtures/alerts.ts +++ b/tests/e2e/fixtures/alerts.ts @@ -4,3 +4,5 @@ export const alertsGroup={id:"group-fixture",name:"Fixture group",combineMode:"a export const alertsWithRule={...emptyAlerts,rules:[alertsRule],groups:[alertsGroup],meta:{...emptyAlerts.meta,servers:["router-fixture"],serversDetail:[{name:"router-fixture",host:"192.0.2.1",site:"",country:"",type:""}]}} export const emptyOutbox={counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20} + +export const heldOutbox={...emptyOutbox,counts:{...emptyOutbox.counts,total:1,unknown:1},items:[{id:"held-fixture",resolutionVersion:3,status:"unknown",ruleName:"Held retry fixture",createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T01:00:00Z"}]}