Асиметрія: аварія о 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>
137 lines
6.3 KiB
TypeScript
137 lines
6.3 KiB
TypeScript
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()
|
||
})
|
||
})
|