From 875819c577241aaf8e7f9009e1f529fbdcbba091 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 18:00:01 +0700 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BA=20Telegram=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D1=82=D1=8C=20=D0=BE=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/alerts/page.tsx | 225 ++-------------------------- components/alerts-telegram-form.tsx | 81 ++++++++++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 11 +- docs/ux-ui-stage-11b.md | 5 + tests/e2e/alerts-context.spec.ts | 12 ++ tests/e2e/alerts-telegram.spec.ts | 50 +++++++ 7 files changed, 170 insertions(+), 216 deletions(-) create mode 100644 components/alerts-telegram-form.tsx create mode 100644 tests/e2e/alerts-telegram.spec.ts diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 74190966..9d8374ee 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import {useAlertsTargets} from "@/hooks/use-alerts-targets" +import {AlertsTelegramForm} from "@/components/alerts-telegram-form" import {AlertsTargetsStatus} from "@/components/alerts-targets-status" import { useDataSource } from "@/lib/data-source" import { PageHeader } from "@/components/page-header" @@ -28,7 +29,7 @@ import { BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon, CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon, TimerIcon, WifiOffIcon, TrendingDownIcon, - EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon, + SendIcon, CheckIcon, Trash2Icon, PencilIcon, ChevronsUpDownIcon, @@ -815,142 +816,6 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled } // ─── telegram config card ───────────────────────────────────────────────────── -function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTelegram, onTestTelegram }: { - cfg: TelegramConfig - onChange: (c: TelegramConfig) => void - /** В режиме live: токен в БД есть, в поле не подставляем */ - tokenConfigured?: boolean - liveSaveBusy?: boolean - onSaveTelegram?: () => void | Promise - onTestTelegram?: () => void | Promise -}) { - const [showToken, setShowToken] = useState(false) - const [testing, setTesting] = useState(false) - const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null) - - const handleTest = async () => { - setTesting(true) - setTestResult(null) - try { - if (onTestTelegram) { - await onTestTelegram() - setTestResult("ok") - } else { - await new Promise((r) => setTimeout(r, 450)) - setTestResult(cfg.connected ? "ok" : "fail") - } - } catch { - setTestResult("fail") - } finally { - setTesting(false) - setTimeout(() => setTestResult(null), 4000) - } - } - - return ( - ✈️ Telegram} - headerRight={ - - - {cfg.connected ? "Подключён" : "Не настроен"} - - } - contentClassName="px-4 pb-4 flex flex-col gap-3" - > - - {/* bot token */} -
- Bot Token -
- onChange({ ...cfg, token: e.target.value })} - autoComplete="new-password" - name="telegram-bot-token" - className="text-xs font-mono h-8" - placeholder={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."} - /> - - -
-
- - {/* default chat id */} -
- Chat ID по умолчанию - onChange({ ...cfg, chatId: e.target.value })} - className="text-xs font-mono h-8" - placeholder="-100xxxxxxxxxx" - /> -

- Для групп используйте отрицательный ID. Каждое правило может переопределить. -

-
- -
- ID темы (message_thread_id) - onChange({ ...cfg, messageThreadId: e.target.value.replace(/\D/g, "") })} - className="text-xs font-mono h-8" - inputMode="numeric" - placeholder="Напр. 12345 — только для супергрупп с темами" - /> - - Опционально: номер топика в форум-супергруппе. Пусто — сообщение в общий чат группы. - -
- - {onSaveTelegram && ( - - )} - - {/* test button */} - -
- ) -} - // ─── history card ───────────────────────────────────────────────────────────── function HistoryCard({ entries }: { entries: HistoryEntry[] }) { @@ -2391,7 +2256,8 @@ function AlertsContent() { const [loadBusy, setLoadBusy] = useState(false) const [loadError, setLoadError] = useState(null) const [rulesSaveBusy, setRulesSaveBusy] = useState(false) - const [telegramSaveBusy, setTelegramSaveBusy] = useState(false) + const telegramLock=useRef(false) + const [telegramBusy,setTelegramBusy]=useState(false) /** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */ const [extrasExpanded, setExtrasExpanded] = useState(false) const [hintsExpanded, setHintsExpanded] = useState(false) @@ -2423,7 +2289,7 @@ function AlertsContent() { }, [isLive, meta]) const load = useCallback(async () => { - if(!pageActive.current)return + if(!pageActive.current||telegramLock.current)return if (!isLive) { setRules(INIT_RULES) setGroups(INIT_GROUPS) @@ -2494,71 +2360,6 @@ function AlertsContent() { } }, [apiFetch, isLive, load]) - const saveTelegram = useCallback(async () => { - if (!isLive) return - const threadRaw = tg.messageThreadId.trim() - let messageThreadId: number | null = null - if (threadRaw) { - const n = Number.parseInt(threadRaw, 10) - if (!Number.isFinite(n) || n < 1) { - setLoadError("ID темы: укажите положительное целое число или оставьте поле пустым") - return - } - messageThreadId = n - } - setTelegramSaveBusy(true) - setLoadError(null) - try { - const res = await apiFetch<{ - telegram: { - chatId: string - tokenConfigured: boolean - connected: boolean - messageThreadId: number | null - } - }>( - "/api/alerts/telegram", - { - method: "PUT", - body: JSON.stringify({ - chatId: tg.chatId, - messageThreadId, - ...(tg.token.trim() ? { token: tg.token.trim() } : {}), - }), - }, - ) - setTokenConfigured(res.telegram.tokenConfigured) - setTg({ - token: "", - chatId: res.telegram.chatId ?? "", - messageThreadId: - res.telegram.messageThreadId != null ? String(res.telegram.messageThreadId) : "", - connected: res.telegram.connected, - }) - } catch (e) { - setLoadError(e instanceof Error ? e.message : "Не удалось сохранить Telegram") - } finally { - setTelegramSaveBusy(false) - } - }, [apiFetch, isLive, tg.chatId, tg.token, tg.messageThreadId]) - - const testTelegram = useCallback(async () => { - if (!isLive) return - const threadRaw = tg.messageThreadId.trim() - const messageThreadId = - threadRaw ? Number.parseInt(threadRaw, 10) : undefined - await apiFetch("/api/alerts/telegram/test", { - method: "POST", - body: JSON.stringify({ - token: tg.token.trim() || undefined, - chatId: tg.chatId.trim() || undefined, - ...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1 - ? { messageThreadId } - : {}), - }), - }) - }, [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId]) - const testRuleTelegramFromForm = useCallback( async (p: RuleTelegramTestPayload) => { if (!isLive) return @@ -2758,7 +2559,7 @@ function AlertsContent() { crumbs={[{ label: "Система" }, { label: "Оповещения" }]} actions={ <> - +
change("chatId",e.target.value)} className="min-w-0 font-mono text-xs"/>

Для групп используйте отрицательный ID. Правило может задать свой чат.

+
change("messageThreadId",e.target.value)} className="min-w-0 font-mono text-xs"/>

Пустое поле при сохранении очищает тему. Только для форум-супергрупп.

+ {dirty?

Есть несохранённые изменения. Обновление страницы данных сохранит черновик.

:null} + {!live?

Демо: сохранение и отправка недоступны.

:!allowed?

Нет права изменять оповещения.

:null} + {error?

{error}

:null} + {outcome?

{outcome}

:null} + {busy?

Ожидаем подтверждение сервера…

:null} + {unknown&&!busy?

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

:null} + + + + +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index a90e5cc4..c4d15d28 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.3.1.2.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.2: Telegram dirty draft/selective save/test/rights/unknown review/source; затем правила, GET/revision/outbox audit и оставшиеся 11Б/12. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. +Этап 11Б в работе; последний проверенный блок 11Б.4.3.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.3: правила и группы — strict receipt, права, общий замок, сохранение формы при ошибках и unknown review; затем GET/revision/outbox audit, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12. Реальные 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 59efbd1c..4371c68b 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.3.1.2.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,15 @@ +## Блок 11Б.4.3.2 — подтверждённые параметры и тест Telegram (2026-10-03) + +- Новая форма на установленном OpsPanel/Frame хранит dirty поля отдельно от полученного snapshot. Refresh сохраняет черновик, untouched поля получают новые данные; PUT передаёт только изменённые поля. Токен не сохраняется в browser storage. Пустой thread отправляется явно null; некорректное число остаётся в форме и не отправляет API. +- Строгие save/test helpers подключены к UI; общий synchronous lock и source AbortController защищают от повторного save/test. Начало записи отменяет pending primary GET и его revision: поздний overview не заменяет подтверждённый save. Controls требуют alerts:write, live и подтверждённую доступность; demo ничего не отправляет. +- Неизвестный результат сохраняет checkpoint без секретов на источник в памяти/sessionStorage, переживает refresh/reload/ABA, требует явной проверки перед повтором. Подтверждённый 400/401/403/409/422 снимает checkpoint, сохраняет draft; network/5xx/malformed остаются unknown. Поздний receipt старого lifetime не снимает новый guard. При недоступности sessionStorage остаётся только memory guard до reload. +- «Подключён» заменён на «Параметры заполнены»; test success обозначает приём Telegram API и просит проверить чат, не доказывает доставку. Очистка draft thread перед тестом требует сначала save: legacy test API при omitted thread использует сохранённое значение. +- 10 Telegram browser сценариев, 4 Telegram source ABA, 7 primary/catalog regression и 2 reader ABA пройдены; typecheck без incremental/lint/ReUI audit. Форма проверена и просмотрена на 320px. Реальные Telegram/PG не вызывались. Rule preview test пока старый и не принят; правила и GET/outbox consistency впереди. +- Следующий точный шаг 11Б.4.3.3: правила и группы — strict receipt, права, общий замок, сохранение формы при ошибках и unknown review; затем GET/revision/outbox audit, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12. + ## Блок 11Б.4.3.1.2.2 — независимые каталоги Alerts (2026-10-03) - Primary strict reader больше не ждёт BGP/server labels/GRE. Каждый каталог имеет собственные abort/revision/lifetime, loading/error/known/time, retry и последний успешный snapshot; header refresh запускает их независимо. Unknown не отображается как пустой каталог. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 2e694239..288d94bd 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -253,3 +253,8 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S ### 11Б.4.3.1.2.2 — каталоги Alerts (2026-10-03) Primary и три вторичных reader независимы, partial GRE сохраняет last good, unknown status не down. OpsPanel/Frame показывает права, ошибки, время и отдельный retry. 9 browser сценариев, typecheck/lint/audit пройдены; реальные сетевые опросы не проверены. Следующий шаг 11Б.4.3.2: Telegram dirty draft/selective save/test/rights/unknown review/source; затем правила, GET/revision/outbox audit и оставшиеся 11Б/12. + + +### 11Б.4.3.2 — Telegram (2026-10-03) + +Dirty/selective save, strict receipt, общий lock, bounded source lifetime, unknown checkpoint без секретов и права alerts:write. 10 профильных browser сценариев + 4 source ABA, регрессия readers, lint/typecheck; форма 320px просмотрена. API acceptance не означает доставку. Rule preview и реальные Telegram/PG пока не приняты. Далее 11Б.4.3.3: правила и группы — strict receipt, права, общий замок, сохранение формы при ошибках и unknown review; затем GET/revision/outbox audit, общий dashboard permission reader, Releases/probe scheduling и приёмка 11Б/12. diff --git a/tests/e2e/alerts-context.spec.ts b/tests/e2e/alerts-context.spec.ts index b76522eb..c69c9cb8 100644 --- a/tests/e2e/alerts-context.spec.ts +++ b/tests/e2e/alerts-context.spec.ts @@ -11,3 +11,15 @@ for(const failed of [false,true])test("Alerts source: ABA discards old primary a try{await page.goto("/alerts");await expect.poll(()=>startedPaths.size).toBe(3);await page.goto("/settings");await source(page,"http://localhost:8101");old=false;await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(started);await expect(page.getByText("OLD ALERTS FAILED",{exact:true})).toHaveCount(0);await expect(page.locator('input[value="OLD CHAT"]')).toHaveCount(0);for(const label of ["BGP","GRE"])await expect(page.getByRole("region",{name:"Каталог "+label,exact:true})).toContainText("Получено объектов: 0") }finally{release()} }) + +const configuredTelegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9} +for(const kind of ["save","test"] as const)for(const failed of [false,true])test("Telegram source: ABA retains unknown checkpoint and ignores old receipt/"+kind+"/"+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,telegram:configuredTelegram}: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/telegram"+(kind==="test"?"/test":""),async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD TELEGRAM FAILED"}}:{headers,json:kind==="test"?{ok:true}:{ok:true,telegram:{...configuredTelegram,chatId:"-100draft"}}})}catch{}finally{completed=true}}) + const form=()=>page.getByRole("region",{name:"Параметры Telegram",exact:true}) + try{await page.goto("/alerts");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(configuredTelegram.chatId);if(kind==="save")await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form().getByRole("button",{name:kind==="save"?"Сохранить в БД":"Отправить тестовое сообщение",exact:true}).click();await expect.poll(()=>started).toBe(true) + await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(form().getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(form()).toContainText("Результат запроса неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(form().getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await expect(form()).not.toContainText("Параметры Telegram сохранены в БД");await expect(form()).not.toContainText("API подтвердил приём");await expect(form()).not.toContainText("OLD TELEGRAM FAILED");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(configuredTelegram.chatId);expect(calls).toBe(1) + }finally{release()} +}) diff --git a/tests/e2e/alerts-telegram.spec.ts b/tests/e2e/alerts-telegram.spec.ts new file mode 100644 index 00000000..6a351af7 --- /dev/null +++ b/tests/e2e/alerts-telegram.spec.ts @@ -0,0 +1,50 @@ +import {test,expect,type Page,type Route} from "@playwright/test" +import {emptyAlerts} from "./fixtures/alerts" +const telegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9} +async function setup(page:Page,handler:(route:Route,path:string)=>Promise,required=false){ + 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==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts"||path.startsWith("/api/alerts/telegram"))return handler(r,path);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + await page.goto("/alerts");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(telegram.chatId) +} +const form=(page:Page)=>page.getByRole("region",{name:"Параметры Telegram",exact:true}) +test("Telegram: dirty fields survive refresh and selective save leaves refreshed fields untouched",async({page})=>{ + let current={...telegram},payload:unknown,calls=0 + await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram:current}});calls++;payload=r.request().postDataJSON();current={...current,...payload as object};return r.fulfill({json:{ok:true,telegram:current}})}) + await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");current={...current,chatId:"-100external",messageThreadId:17};await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true})).toHaveValue("17");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft") + await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByText("Параметры Telegram сохранены в БД",{exact:true})).toBeVisible();expect(payload).toEqual({chatId:"-100draft"});expect(calls).toBe(1);await expect(form(page).getByRole("button",{name:"Сохранить в БД",exact:true})).toBeDisabled();await expect(form(page)).not.toContainText("Подключён") +}) +test("Telegram: invalid thread is preserved and no PUT or POST is sent",async({page})=>{ + let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:true}})}) + const input=page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true});for(const value of ["12wrong","1.5","0","2147483648"]){await input.fill(value);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("ID темы");await expect(input).toHaveValue(value);await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("ID темы")};expect(calls).toBe(0) +}) +test("Telegram: malformed save remains unknown across refresh and reload without secret storage",async({page})=>{ + let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:false}})}) + await page.locator("#alerts-bot-token").fill("fixture-secret");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("не подтверждено");await expect(page.locator("#alerts-bot-token")).toHaveValue("fixture-secret");await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.locator("#alerts-bot-token")).toHaveValue("fixture-secret");expect(await page.evaluate(()=>JSON.stringify(sessionStorage))).not.toContain("fixture-secret");await page.reload();await expect(form(page)).toContainText("Результат запроса неизвестен");await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();expect(calls).toBe(1) +}) +test("Telegram: confirmed rejection preserves draft and allows corrected save",async({page})=>{ + let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({status:403,json:{error:"Fixture denied"}})}) + await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page).getByRole("alert")).toHaveText("Fixture denied");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft");await expect(form(page).getByRole("button",{name:"Сохранить в БД",exact:true})).toBeEnabled();await expect(form(page)).not.toContainText("Результат запроса неизвестен");expect(calls).toBe(1) +}) +test("Telegram: one pending write excludes save/test and refresh until strict receipt",async({page})=>{ + let calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + try{await setup(page,async(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;await gate;return r.fulfill({json:{ok:true,telegram:{...telegram,chatId:"-100draft"}}})});await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect.poll(()=>calls).toBe(1);await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeDisabled();await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toBeDisabled();release();await expect(form(page).getByText("Параметры Telegram сохранены в БД",{exact:true})).toBeVisible();expect(calls).toBe(1)}finally{release()} +}) +test("Telegram: strict test receipt acknowledges API acceptance without claiming delivery",async({page})=>{ + let calls=0,payload:unknown;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;payload=r.request().postDataJSON();return r.fulfill({json:{ok:true}})}) + await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page)).toContainText("Telegram API подтвердил приём тестового сообщения. Проверьте его в чате.");expect(payload).toEqual({chatId:telegram.chatId,messageThreadId:9});expect(calls).toBe(1);await expect(form(page)).not.toContainText("Сообщение отправлено") +}) +test("Telegram: alerts read does not grant config save or test",async({page})=>{ + const token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions:["mm:alerts:read"],exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token) + let calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;return r.fulfill({json:{ok:true}})},true);await expect(form(page)).toContainText("Нет права изменять оповещения");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toBeDisabled();await expect(form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true})).toBeDisabled();expect(calls).toBe(0) +}) + +test("Telegram: compact form remains within 320px viewport",async({page})=>{await page.setViewportSize({width:320,height:900});await setup(page,r=>r.fulfill({json:{...emptyAlerts,telegram}}));await form(page).scrollIntoViewIfNeeded();const box=await form(page).boundingBox();expect(box).not.toBeNull();expect(box!.x).toBeGreaterThanOrEqual(0);expect(box!.x+box!.width).toBeLessThanOrEqual(320);await page.screenshot({path:"test-results/alerts-telegram-320.png",fullPage:true})}) + +test("Telegram: clearing thread is explicit and test cannot silently use the saved topic",async({page})=>{ + let payload:unknown,calls=0;await setup(page,(r,path)=>{if(path==="/api/alerts")return r.fulfill({json:{...emptyAlerts,telegram}});calls++;payload=r.request().postDataJSON();return r.fulfill({json:{ok:true,telegram:{...telegram,messageThreadId:null}}})}) + await page.getByRole("textbox",{name:"ID темы (message_thread_id)",exact:true}).fill("");await form(page).getByRole("button",{name:"Отправить тестовое сообщение",exact:true}).click();await expect(form(page).getByRole("alert")).toContainText("Сначала сохраните очистку ID темы");expect(calls).toBe(0);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page)).toContainText("Параметры Telegram сохранены в БД");expect(payload).toEqual({messageThreadId:null});expect(calls).toBe(1) +}) +test("Telegram: late overview GET cannot replace a confirmed save",async({page})=>{ + let delayed=false,started=false,completed=false,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + try{await setup(page,async(r,path)=>{if(path==="/api/alerts"){if(delayed){started=true;await gate;try{await r.fulfill({json:{...emptyAlerts,telegram:{...telegram,chatId:"OLD CHAT"}}})}catch{}finally{completed=true};return}return r.fulfill({json:{...emptyAlerts,telegram}})}return r.fulfill({json:{ok:true,telegram:{...telegram,chatId:"-100draft"}}})});await page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true}).fill("-100draft");delayed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>started).toBe(true);await form(page).getByRole("button",{name:"Сохранить в БД",exact:true}).click();await expect(form(page)).toContainText("Параметры Telegram сохранены в БД");release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue("-100draft")}finally{release()} +})