Асиметрія: аварія о 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>
122 lines
5 KiB
Go
122 lines
5 KiB
Go
package store
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// Тиха година 22:00–08: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)
|
||
}
|
||
}
|