Netpulse_SasS/server/internal/store/alerts_escalation_quiet_test.go
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

122 lines
5 KiB
Go
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.

package store
import (
"testing"
"time"
)
// Тиха година 22:0008:00 за Києвом, щодня.
func nightSchedule() *RouteSchedule {
var s RouteSchedule
s.TZ = "Europe/Kyiv"
s.Quiet = append(s.Quiet, struct {
Days []int `json:"days"`
From string `json:"from"`
To string `json:"to"`
}{From: "22:00", To: "08:00"})
return &s
}
func quietSnap(respect bool, severity string) EscalationSnapshot {
kyiv, _ := time.LoadLocation("Europe/Kyiv")
start := time.Date(2026, 8, 28, 21, 0, 0, 0, kyiv)
return EscalationSnapshot{
AlertID: "a1",
TenantID: "t1",
PolicyID: "p1",
AlertState: "firing",
StepIdx: 0,
PassStart: start,
Deadline: start.Add(48 * time.Hour),
Steps: []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"c1"}}},
RespectQuietHours: respect,
Schedule: nightSchedule(),
Alert: Alert{Severity: severity, State: "firing"},
}
}
// Кабінет сказав «драбина вночі мовчить» — сходинка чекає ранку.
//
// І головне: вона НЕ витрачається. Списана сходинка означала б, що одна
// ніч тихо роззброює драбину до кінця життя алерту, тобто рівно та
// мовчазна відмова, від якої ескалація й рятує.
func TestQuietHoursDeferStepWhenRespected(t *testing.T) {
kyiv, _ := time.LoadLocation("Europe/Kyiv")
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
d := PlanEscalation(quietSnap(true, "high"), night)
if d.Action != EscDefer {
t.Fatalf("сходинка мала відкластись, а не %v (%s)", d.Action, d.Outcome)
}
if d.Outcome != "quiet_hours" {
t.Errorf("причина відкладання нечитабельна: %q", d.Outcome)
}
if d.NextStepIdx != 0 {
t.Errorf("сходинку витрачено: наступна %d замість 0", d.NextStepIdx)
}
if d.NextAt == nil || !d.NextAt.After(night) {
t.Error("немає часу повторної перевірки — драбина застрягне")
}
}
// Той самий кабінет удень: тиха година скінчилась, драбина йде.
func TestQuietHoursDoNotHoldByDay(t *testing.T) {
kyiv, _ := time.LoadLocation("Europe/Kyiv")
day := time.Date(2026, 8, 28, 11, 0, 0, 0, kyiv)
if d := PlanEscalation(quietSnap(true, "high"), day); d.Action != EscFire {
t.Fatalf("удень сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
}
}
// Кабінет НЕ просив зважати на тиху годину — драбина пробивається.
//
// Це типове значення й поведінка, що діяла досі: оновлення не має тихо
// роззброїти нічні дзвінки в тих, у кого вони працюють.
func TestQuietHoursIgnoredByDefault(t *testing.T) {
kyiv, _ := time.LoadLocation("Europe/Kyiv")
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
if d := PlanEscalation(quietSnap(false, "high"), night); d.Action != EscFire {
t.Fatalf("драбина без прапорця мала пробитись: %v/%s", d.Action, d.Outcome)
}
}
// Disaster будить попри все — так само, як у першому сповіщенні.
//
// Одне слово в двох місцях мусить означати одне: targets() уже пропускає
// disaster крізь тиху годину маршруту й правила.
func TestDisasterBreaksThroughQuietHours(t *testing.T) {
kyiv, _ := time.LoadLocation("Europe/Kyiv")
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
if d := PlanEscalation(quietSnap(true, "disaster"), night); d.Action != EscFire {
t.Fatalf("disaster мав пробитись крізь тиху годину: %v/%s", d.Action, d.Outcome)
}
}
// Драбина, яку ніч не пускала аж до стелі життя, зупиняється — а не
// відкладається вічно.
func TestQuietHoursCannotOutliveDeadline(t *testing.T) {
kyiv, _ := time.LoadLocation("Europe/Kyiv")
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
s := quietSnap(true, "high")
s.Deadline = night.Add(time.Minute)
d := PlanEscalation(s, night)
if d.Action != EscStop || d.Outcome != "deadline" {
t.Fatalf("драбина мала протухнути, а не %v/%s", d.Action, d.Outcome)
}
}
// Розкладу немає — тиха година не може тримати те, чого не описано.
func TestNoScheduleNoHold(t *testing.T) {
kyiv, _ := time.LoadLocation("Europe/Kyiv")
night := time.Date(2026, 8, 28, 23, 30, 0, 0, kyiv)
s := quietSnap(true, "high")
s.Schedule = nil
if d := PlanEscalation(s, night); d.Action != EscFire {
t.Fatalf("без розкладу сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
}
}