fix(alerts): подтвердить риск ручного повтора сообщения
This commit is contained in:
1 parent
7d0608bbce
commit
b109532f33
11 files changed
+103
-16
No files matched your search
@@ -2562,7 +2562,7 @@ function AlertsContent() {
|
||||
{config.busy?<p role="status" className="text-xs">Ожидаем подтверждение правил и групп…</p>:null}
|
||||
{config.unknown&&!config.busy?<div className="space-y-2"><p role="status" className="text-xs">Результат сохранения правил неизвестен. Запрос мог выполниться; проверьте правила и группы в БД перед повтором. Обновление списка не разрешает повтор автоматически.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!config.enabled||operationBusy} onClick={config.review}>Я проверил правила — разрешить новый запрос</Button></div>:null}
|
||||
{isLive?<AlertsTargetsStatus catalogs={catalogs}/>:null}
|
||||
{isLive?<AlertsOutboxPanel base={backendUrl}/>:null}
|
||||
{isLive?<AlertsOutboxPanel base={backendUrl} available={backendStatus===true} operationsBusy={operationBusy} beginOperation={beginOperation} endOperation={endOperation}/>:null}
|
||||
{known?<>
|
||||
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
|
||||
|
||||
@@ -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<AlertsOutboxOverview["items"][number]|null>(null),[ack,setAck]=useState(false),[reviewed,setReviewed]=useState(false),[mutationBusy,setMutationBusy]=useState(false),[mutationError,setMutationError]=useState<string|null>(null),[message,setMessage]=useState<string|null>(null)
|
||||
const mutationLock=useRef(false),writeController=useRef<AbortController|null>(null),writeRevision=useRef(0)
|
||||
const writable=can("mm:alerts:write")&&available&&!operationsBusy&&!unknown&&!mutationBusy
|
||||
|
||||
const [data,setData]=useState<AlertsOutboxOverview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[receivedAt,setReceivedAt]=useState<string|null>(null)
|
||||
const active=useRef(true),revision=useRef(0),controller=useRef<AbortController|null>(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 <section aria-label="Очередь Telegram" className="min-w-0"><OpsPanel title="Очередь Telegram" description="Приём сообщения API и доставка в чат — разные состояния." headerRight={<Button type="button" variant="outline" size="sm" aria-label="Обновить очередь Telegram" disabled={busy} onClick={()=>void refresh()}>Обновить</Button>} 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 <section aria-label="Очередь Telegram" className="min-w-0"><OpsPanel title="Очередь Telegram" description="Приём сообщения API и доставка в чат — разные состояния." headerRight={<Button type="button" variant="outline" size="sm" aria-label="Обновить очередь Telegram" disabled={busy||mutationBusy} onClick={()=>void refresh()}>Обновить</Button>} contentClassName="space-y-3 px-5 py-4">
|
||||
{error?<p role="alert" className="text-xs text-destructive break-words">{error}{data?". Показан последний успешный снимок очереди.":""}</p>:null}
|
||||
<p role="status" className="text-xs text-muted-foreground">{busy?"Обновление очереди…":data?"Последний успешный ответ: "+new Date(receivedAt!).toLocaleString("ru-RU"):"Данные очереди ещё не получены."}</p>
|
||||
{message?<p role="status" className="text-xs break-words">{message}</p>:null}
|
||||
{mutationError?<p role="alert" className="text-xs text-destructive break-words">{mutationError}</p>:null}
|
||||
{unknown?<div className="space-y-2 text-xs"><p role="status">{mutationBusy?"Постановка в очередь выполняется…":"Результат повтора неизвестен. Автоматический повтор заблокирован. Проверьте очередь и чат: сообщение могло быть отправлено."}</p><label className="flex items-start gap-2"><input type="checkbox" checked={reviewed} disabled={mutationBusy} onChange={e=>setReviewed(e.target.checked)}/>Проверил очередь и чат</label><Button type="button" size="sm" variant="outline" disabled={!reviewed||mutationBusy} onClick={()=>{setOutboxPending(source,false);setReviewed(false);void refresh()}}>Результат повтора проверен</Button></div>:null}
|
||||
{data?<>
|
||||
<p className="text-sm">Ожидают: {data.counts.pending} · API принял: {data.counts.sent} · Завершены с ошибкой: {data.counts.failed}</p>
|
||||
{data.counts.unknown||data.counts.processing||data.counts.unrecognized?<p role="status" className="text-xs break-words">Нужна проверка: неизвестный результат — {data.counts.unknown}, в обработке — {data.counts.processing}, нераспознанный статус — {data.counts.unrecognized}. Эти записи автоматически не переотправляются. Обработка может означать текущий запрос или прерванный процесс отправки; проверьте чат и журнал перед повтором.</p>:null}
|
||||
<p className="text-xs text-muted-foreground">Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повторная отправка из этого экрана пока недоступна.</p>
|
||||
{data.items.length?<ul className="space-y-2">{data.items.map(item=><li key={item.id} className="break-words text-xs"><p className="font-medium">{item.ruleName||item.id}</p><p className="text-muted-foreground">{item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}</p></li>)}</ul>:null}
|
||||
<p className="text-xs text-muted-foreground">Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повтор разрешён только для неизвестного результата после проверки чата.</p>
|
||||
{data.items.length?<ul className="space-y-2">{data.items.map(item=><li key={item.id} className="break-words text-xs"><p className="font-medium">{item.ruleName||item.id}</p><p className="text-muted-foreground">{item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}</p>{item.status==="unknown"?<Button type="button" size="sm" variant="outline" aria-label={"Повторить сообщение "+(item.ruleName||item.id)} disabled={!writable||item.resolutionVersion===2147483647} onClick={()=>{setSelected(item);setAck(false);setMutationError(null)}}>Повторить после проверки</Button>:null}</li>)}</ul>:null}
|
||||
</>:null}
|
||||
</OpsPanel></section>
|
||||
</OpsPanel>
|
||||
<AlertDialog open={selected!==null} onOpenChange={open=>{if(!open&&!mutationLock.current)setSelected(null)}}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Повторить сообщение?</AlertDialogTitle><AlertDialogDescription>{selected?.ruleName||selected?.id}. Предыдущая попытка могла доставить сообщение. Повтор может создать дубликат; повтор использует сохранённое сообщение и настройки подключения Telegram на момент новой попытки.</AlertDialogDescription></AlertDialogHeader><label className="flex items-start gap-2 text-sm"><input type="checkbox" checked={ack} disabled={mutationBusy} onChange={e=>setAck(e.target.checked)}/>Проверил чат и принимаю риск дубликата</label>{mutationError?<p role="alert" className="text-xs text-destructive break-words">{mutationError}</p>:null}<AlertDialogFooter><Button type="button" variant="outline" disabled={mutationBusy} onClick={()=>setSelected(null)}>Отмена</Button><Button type="button" disabled={!ack||!writable} onClick={()=>void retry()}>{mutationBusy?"Постановка в очередь…":"Поставить в очередь повторно"}</Button></AlertDialogFooter></AlertDialogContent></AlertDialog>
|
||||
</section>
|
||||
}
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
+10
-1
@@ -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.
|
||||
|
||||
@@ -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 остаётся без повтора до отдельного сценария подтверждённой остановки отправителя.
|
||||
@@ -0,0 +1,6 @@
|
||||
// Persists ambiguous manual queue retry per source; never stores payload or credentials.
|
||||
const pending=new Set<string>(),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()}
|
||||
@@ -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}
|
||||
})
|
||||
@@ -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<unknown>(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
|
||||
}
|
||||
@@ -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<void>(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()}
|
||||
})
|
||||
@@ -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<unknown>,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<void>(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)})
|
||||
@@ -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"}]}
|
||||
Reference in new issue
Block a user