Netpulse_SasS/web/src/test/escalationchannels.test.tsx
byrsapty ec4b2cd54b
All checks were successful
CI / hygiene (push) Successful in 10s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m54s
CI / agent (push) Successful in 1m1s
Тиха година й драбина: вада окремо, вибір окремо
Асиметрія: аварія о 21:59 ескалювала всю ніч, о 22:01 не ескалювала
ніколи. Дві хвилини різниці — протилежні наслідки, причому гірший
(повна тиша) виглядав як тиша справна.

ВАДА. targets() повертав порожньо в тиху годину, а взведення читало це
як «немає куди слати». Взводять лише новий алерт, тож драбина не
з'являлась уже ніколи: тиха година вимикала механізм саме тоді, коли
перше сповіщення не спрацювало. Тепер targets() розрізняє «каналів
немає» і «канали є, просто зараз ніч».

ВИБІР. 0072 додає respect_quiet_hours на драбину:
  false (типово, як діяло) — драбина пробивається;
  true  — сходинка відкладається до ранку і НЕ витрачається.
Залежить від того, чи є в кабінету нічна зміна — це вирішує кабінет.
disaster пробивається за будь-якого значення, як і в targets().

Відлік драбини — від першого сповіщення, а не від started_at: інакше
для розглушеного алерту вона протухла б ще у вікні.

І сам прогін проти бази брехав: dbtest.sh котив схему готовим образом
(старі міграції), а тести брав із нового дерева. Тепер міграції з того
ж дерева. 64 міграції, усе зелене.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:08:27 +03:00

137 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EscalationsPage } from '../pages/EscalationsPage'
import { ChannelsPage } from '../pages/ChannelsPage'
import { session } from '../api/session'
import { fetchRouter, res, type Call } from './support'
/**
* Канал, на який посилається сходинка драбини.
*
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Драбина зберігалась із будь-яким
* ідентифікатором каналу, а видалення каналу не чіпало сходинок, що на
* нього посилались. Ззовні це виглядало як робоче нічне чергування:
* сходинка стоїть, хвилини стоять, у стовпчику каналів — прочерк, який
* читається як «не заповнено» й нікого не турбує. Насправді ця
* сходинка не йде нікуди й списується мовчки.
*
* У формі це давало ще й глухий кут: галочки для видаленого каналу в
* переліку немає, тобто прибрати його руками неможливо, а сервер
* (після виправлення) такої драбини вже не приймає.
*
* Форма підставних відповідей звірена з
* `server/internal/httpapi/alerts.go` (`handleListEscalationPolicies`,
* `handleListChannels`) і зі `store.EscalationPolicy` / `store.Channel`.
*/
const duty = {
id: 'ch-duty',
kind: 'telegram',
name: 'Черговий',
config: {},
min_severity: 'warning',
enabled: true,
has_secret: true,
}
/** Драбина, друга сходинка якої посилається на вже видалений канал. */
const policy = {
id: 'p-1',
name: 'Нічне чергування',
description: '',
steps: [
{ after_min: 15, channel_ids: ['ch-duty'] },
{ after_min: 45, channel_ids: ['ch-gone'] },
],
repeat_after_min: 0,
max_repeats: 0,
rule_count: 2,
}
beforeEach(() => {
vi.stubGlobal('WebSocket', class {})
session.set('tok', {
userID: 'u-me',
username: 'me',
tenantID: 't-1',
permissions: ['alerts:read', 'alerts:write'],
})
})
describe('сходинка, що посилається на видалений канал', () => {
it('у переліку її видно словами, а не прочерком', async () => {
fetchRouter({
'GET /api/v1/escalation-policies': { policies: [policy] },
'GET /api/v1/channels': { channels: [duty] },
})
render(<EscalationsPage />)
await screen.findByText('Черговий')
// Прочерк читався б як «нічого не налаштовано», тобто як спокійний
// стан. Тут стан протилежний.
expect(await screen.findByText('канал видалено')).toBeTruthy()
})
it('форма прибирає привида, говорить про це і не шле його на сервер', async () => {
const srv = fetchRouter({
'GET /api/v1/escalation-policies': { policies: [policy] },
'GET /api/v1/channels': { channels: [duty] },
'PUT /api/v1/escalation-policies/p-1': () => res(200, { id: 'p-1' }),
})
render(<EscalationsPage />)
fireEvent.click(await screen.findByRole('button', { name: 'Змінити' }))
await screen.findByText('Драбина: Нічне чергування')
expect(screen.getByText(/лишались посилання на видалені канали/)).toBeTruthy()
// Друга сходинка лишилась без каналу — і форма має сказати про це
// ДО збереження, а не віддати серверу відмову без пояснення.
expect(screen.getByText(/сходинка 2 не має жодного каналу/)).toBeTruthy()
// Дали сходинці канал — тепер зберігається.
//
// Шукаємо саме галочку каналу, а не «останню на формі»: у формі є й
// галочки, що каналів не стосуються (тиха година), і «остання»
// мовчки перестала б робити те, заради чого цей рядок написано —
// тест ловив би відсутність PUT замість зниклого каналу.
fireEvent.click(screen.getAllByRole('checkbox', { name: /Черговий/ })[1])
fireEvent.click(screen.getByRole('button', { name: 'Зберегти' }))
let put: Call | undefined
await waitFor(() => {
put = srv.calls.find((c) => c.method === 'PUT')
expect(put).toBeTruthy()
})
const body = put!.body as { steps: { channel_ids: string[] }[] }
expect(body.steps.map((s) => s.channel_ids)).toEqual([['ch-duty'], ['ch-duty']])
})
})
describe('видалення каналу, на який спирається драбина', () => {
it('називає драбини, які через це замовкнуть', async () => {
fetchRouter({
'GET /api/v1/channels': {
channels: [{ ...duty, escalations: ['Нічне чергування', 'Вихідні'] }],
},
})
render(<ChannelsPage />)
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
// Те саме, що вже показує видалення драбини: скільки чужих
// налаштувань зараз перестане працювати, і яких саме.
expect(await screen.findByText(/Нічне чергування, Вихідні/)).toBeTruthy()
})
it('без драбин попередження про них не вигадується', async () => {
fetchRouter({ 'GET /api/v1/channels': { channels: [duty] } })
render(<ChannelsPage />)
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
await screen.findByText(/більше не доставлятимуться/)
expect(screen.queryByText(/драбини ескалації/)).toBeNull()
})
})