Netpulse_SasS/server/internal/store/alerts_escalation_db_test.go
byrsapty 954be1d643
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m15s
CI / server (push) Successful in 1m34s
CI / agent (push) Successful in 2m59s
Ескалації: закриваю те, що минулого разу закрив наполовину
За другою рецензією:

* scripts/dbtest.sh писав у шапці «не напрямляйте на робочу базу» й
  нічого для цього не робив — перевірено, пішов котити міграції на базу
  з бойовим іменем. Тепер вимагає probe/test в імені.
* sendText ковтав помилку, тож журнал ескалацій писав «надіслано» на
  сходинці, жодне повідомлення якої не дійшло. Три результати замість
  двох: no_channels, failed, sent.
* stopped_at IS NULL рятував лише від ack; гасіння правилом і
  ResolveMissing рядка драбини не чіпають, і сходинка дзвонила за
  погашеним алертом. Додано перевірку стану алерту в тому ж UPDATE.
* escalate() блокував весь тік движка — мертвий вебхук одного кабінету
  зупиняв обчислення правил усім. Винесено в RunEscalations.
* алерт, народжений під заглушенням, не сповіщався ніколи: ні при
  народженні, ні коли вікно скінчилось. Тепер перехід suppressed→firing
  сповіщається, а драбина рахує час від першого сповіщення.
* alr.rules.channel_ids приймав чужі канали, глушачи і сповіщення, і
  драбину. Перевірка як для сходинок; DeleteChannel чистить посилання.

І перше, що зловив прогін проти справжньої бази: nil-зріз каналів їде
явним NULL повз DEFAULT '{}' — правило без каналів давало 500.

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

388 lines
15 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 (
"context"
"os"
"strings"
"testing"
"time"
)
// Перевірка ескалації ПРОТИ БАЗИ.
//
// Чиста PlanEscalation покриває рішення, і це головна половина. Але є
// друга, яку рішенням не перевіриш узагалі: атомарність. «Сходинка не
// подвоїлась при перезапуску» — це властивість трьох запитів
// (взведення з ON CONFLICT, взяття з орендою, запис рішення), а не
// властивість функції. Тест, який їх не виконує, про них нічого не
// каже — скільки б зелених рядків він не давав.
//
// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має
// вимагати бази. Запускати треба на ОДНОРАЗОВІЙ базі — тест створює
// кабінет і видаляє його разом з усім вмістом:
//
// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \
// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16
// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
// go test ./internal/store/ -run EscalationAgainstDB -v
func TestEscalationAgainstDB(t *testing.T) {
dsn := os.Getenv("NETPULSE_TEST_DSN")
if dsn == "" {
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
}
ctx := context.Background()
st, err := New(ctx, dsn)
if err != nil {
t.Fatalf("підключення: %v", err)
}
t.Cleanup(st.Close)
slug := "esc-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
var tenantID string
if err := st.pool.QueryRow(ctx, `
INSERT INTO core.tenants (slug, name) VALUES ($1, $2) RETURNING id::text
`, slug, "Перевірка ескалацій").Scan(&tenantID); err != nil {
t.Fatalf("кабінет: %v", err)
}
t.Cleanup(func() {
_, _ = st.pool.Exec(context.Background(),
`DELETE FROM core.tenants WHERE id = $1`, tenantID)
})
var deviceID string
if err := st.pool.QueryRow(ctx, `
INSERT INTO inv.devices (tenant_id, name, address, kind)
VALUES ($1, $2, '10.77.0.1', 'switch') RETURNING id::text
`, tenantID, slug+"-sw").Scan(&deviceID); err != nil {
t.Fatalf("хост: %v", err)
}
// Канал справжній, а не рядок «ch»: збереження драбини тепер
// відмовляє в сходинці, що посилається на неіснуючий канал.
var channelID string
if err := st.pool.QueryRow(ctx, `
INSERT INTO alr.channels (tenant_id, kind, name, config, min_severity, enabled)
VALUES ($1, 'webhook', $2, '{}'::jsonb, 'warning', true) RETURNING id::text
`, tenantID, slug+"-ch").Scan(&channelID); err != nil {
t.Fatalf("канал: %v", err)
}
policy := EscalationPolicy{
Name: "Нічне чергування",
Steps: []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{channelID}},
{AfterMin: 45, ChannelIDs: []string{channelID}},
},
}
policyID, err := st.SaveEscalationPolicy(ctx, tenantID, "", policy)
if err != nil {
t.Fatalf("політика: %v", err)
}
var ruleID string
if err := st.pool.QueryRow(ctx, `
INSERT INTO alr.rules (tenant_id, name, source, severity, condition, escalation_policy_id)
VALUES ($1, 'Пінг', 'icmp', 'high', '{"metric":"loss_pct","op":">","value":20}'::jsonb, $2)
RETURNING id::text
`, tenantID, policyID).Scan(&ruleID); err != nil {
t.Fatalf("правило: %v", err)
}
newAlert := func(key string) string {
t.Helper()
var id string
if err := st.pool.QueryRow(ctx, `
INSERT INTO alr.alerts (tenant_id, rule_id, device_id, severity, state,
title, dedup_key, started_at, last_seen_at)
VALUES ($1, $2, $3, 'high', 'firing', 'ядро не відповідає', $4,
now() - interval '20 minutes', now())
RETURNING id::text
`, tenantID, ruleID, deviceID, key).Scan(&id); err != nil {
t.Fatalf("алерт: %v", err)
}
return id
}
started := time.Now().Add(-20 * time.Minute)
// --- Взведення не подвоюється -------------------------------------
//
// Найтонше місце всієї ескалації: повторна доставка того самого
// алерту (ретрай, другий інстанс, перезапуск між надсиланням і
// записом) не має перезапускати драбину з нуля.
a1 := newAlert(ruleID + ":dev:" + deviceID)
for i := 0; i < 3; i++ {
if err := st.ArmEscalation(ctx, tenantID, a1, policyID, false, policy, started); err != nil {
t.Fatalf("взведення %d: %v", i, err)
}
}
var rows int
if err := st.pool.QueryRow(ctx,
`SELECT count(*)::int FROM alr.alert_escalations WHERE alert_id = $1`, a1).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("три взведення дали %d рядків драбини — драбина подвоїлась", rows)
}
// --- Оренда не дає взяти сходинку двічі ---------------------------
due, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatalf("взяття сходинки: %v", err)
}
if len(due) != 1 {
t.Fatalf("очікували одну сходинку, отримали %d", len(due))
}
if due[0].AlertState != "firing" || len(due[0].Steps) != 2 {
t.Fatalf("знімок прочитано неправильно: %+v", due[0])
}
again, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("та сама сходинка взята вдруге під орендою: %d", len(again))
}
// --- Запис рішення просуває драбину й веде журнал ------------------
d := PlanEscalation(due[0], time.Now())
if d.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", d.Action, d.Outcome)
}
applied, err := st.ApplyEscalation(ctx, due[0], d)
if err != nil {
t.Fatalf("запис рішення: %v", err)
}
if !applied {
t.Fatal("рішення не застосовано до живої драбини")
}
if err := st.LogEscalationStep(ctx, due[0], d, d.Outcome, d.Detail); err != nil {
t.Fatalf("журнал сходинки: %v", err)
}
var stepIdx int
var leased *time.Time
if err := st.pool.QueryRow(ctx, `
SELECT step_idx, leased_until FROM alr.alert_escalations WHERE alert_id = $1
`, a1).Scan(&stepIdx, &leased); err != nil {
t.Fatal(err)
}
if stepIdx != 1 {
t.Errorf("драбина не просунулась: step_idx = %d", stepIdx)
}
if leased != nil {
t.Error("оренда не знята після запису рішення")
}
var logged int
if err := st.pool.QueryRow(ctx,
`SELECT count(*)::int FROM alr.escalation_steps WHERE alert_id = $1 AND outcome = 'sent'`,
a1).Scan(&logged); err != nil {
t.Fatal(err)
}
if logged != 1 {
t.Errorf("журнал сходинок порожній або задвоєний: %d", logged)
}
// --- Підтвердження зупиняє драбину негайно -------------------------
if _, err := st.AckAlert(ctx, tenantID, a1, "", "беру"); err != nil {
t.Fatalf("підтвердження: %v", err)
}
var stopReason string
var nextAt *time.Time
if err := st.pool.QueryRow(ctx, `
SELECT COALESCE(stop_reason,''), next_at
FROM alr.alert_escalations WHERE alert_id = $1
`, a1).Scan(&stopReason, &nextAt); err != nil {
t.Fatal(err)
}
if stopReason != "acked" {
t.Errorf("драбину не зупинено підтвердженням: причина %q", stopReason)
}
if nextAt != nil {
t.Error("у зупиненої драбини лишився час наступної сходинки")
}
// І головне: зупинена драбина більше не потрапляє в чергу.
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET leased_until = NULL WHERE alert_id = $1`, a1); err != nil {
t.Fatal(err)
}
rest, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatal(err)
}
for _, r := range rest {
if r.AlertID == a1 {
t.Fatal("підтверджений алерт повернувся в чергу ескалації")
}
}
// --- Підтвердження ПОСЕРЕД партії не має воскрешати драбину --------
//
// Найдовший розрив у механізмі: сходинку взяли в чергу, і поки до неї
// дійшли руки (кожна доставка попередніх — з власним таймаутом),
// людина натиснула «Прийняти». Тут відтворено саме цей порядок:
// знімок узято ДО ack, рішення застосовується ПІСЛЯ.
a3 := newAlert(ruleID + ":dev:" + deviceID + ":3")
if err := st.ArmEscalation(ctx, tenantID, a3, policyID, false, policy, started); err != nil {
t.Fatal(err)
}
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
a3); err != nil {
t.Fatal(err)
}
dueRace, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatal(err)
}
var snap EscalationSnapshot
for _, x := range dueRace {
if x.AlertID == a3 {
snap = x
}
}
if snap.AlertID == "" {
t.Fatal("сходинка не потрапила в чергу")
}
dRace := PlanEscalation(snap, time.Now())
if dRace.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dRace.Action, dRace.Outcome)
}
if _, err := st.AckAlert(ctx, tenantID, a3, "", "беру"); err != nil {
t.Fatalf("підтвердження: %v", err)
}
appliedRace, err := st.ApplyEscalation(ctx, snap, dRace)
if err != nil {
t.Fatal(err)
}
if appliedRace {
t.Error("рішення застосовано до вже зупиненої драбини — людину розбудять після ack")
}
var raceStop string
var raceNext *time.Time
if err := st.pool.QueryRow(ctx, `
SELECT COALESCE(stop_reason,''), next_at
FROM alr.alert_escalations WHERE alert_id = $1
`, a3).Scan(&raceStop, &raceNext); err != nil {
t.Fatal(err)
}
if raceStop != "acked" || raceNext != nil {
t.Errorf("зупинену драбину воскрешено: причина %q, наступна %v", raceStop, raceNext)
}
// --- Гасіння правилом посеред партії теж не має дзвонити ------------
//
// Той самий розрив, що й з ack, але шляхом, який рядка драбини НЕ
// чіпає: resolveRuleAlerts переводить алерт у 'resolved' і про
// драбину нічого не знає. Знімок узято ДО гасіння, рішення
// застосовується ПІСЛЯ.
a4 := newAlert(ruleID + ":dev:" + deviceID + ":4")
if err := st.ArmEscalation(ctx, tenantID, a4, policyID, false, policy, started); err != nil {
t.Fatal(err)
}
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
a4); err != nil {
t.Fatal(err)
}
dueRule, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatal(err)
}
var snapRule EscalationSnapshot
for _, x := range dueRule {
if x.AlertID == a4 {
snapRule = x
}
}
if snapRule.AlertID == "" {
t.Fatal("сходинка не потрапила в чергу")
}
dRule := PlanEscalation(snapRule, time.Now())
if dRule.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dRule.Action, dRule.Outcome)
}
// Гасимо так, як це робить вимкнення правила: рядок драбини не чіпаємо.
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alerts SET state = 'resolved', resolved_at = now() WHERE id = $1`,
a4); err != nil {
t.Fatal(err)
}
appliedRule, err := st.ApplyEscalation(ctx, snapRule, dRule)
if err != nil {
t.Fatal(err)
}
if appliedRule {
t.Error("сходинка спрацювала за погашеним алертом — дзвінок ні про що")
}
// І драбина не застрягла: наступний такт зупинить її штатно.
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET leased_until = NULL WHERE alert_id = $1`, a4); err != nil {
t.Fatal(err)
}
againRule, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatal(err)
}
var back bool
for _, x := range againRule {
if x.AlertID != a4 {
continue
}
back = true
dd := PlanEscalation(x, time.Now())
if dd.Action != EscStop || dd.Outcome != "closed" {
t.Errorf("наступний такт мав зупинити драбину: %v/%s", dd.Action, dd.Outcome)
}
okStop, err := st.ApplyEscalation(ctx, x, dd)
if err != nil {
t.Fatal(err)
}
if !okStop {
t.Error("зупинку не записано — драбина застрягла назавжди")
}
}
if !back {
t.Error("сходинка не повернулась у чергу — драбину нікому зупинити")
}
// --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ----
a2 := newAlert(ruleID + ":dev:" + deviceID + ":2")
if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil {
t.Fatal(err)
}
if err := st.CloseAlert(ctx, tenantID, a2); err != nil {
t.Fatalf("закриття: %v", err)
}
if _, err := st.pool.Exec(ctx, `
UPDATE alr.alert_escalations
SET stopped_at = NULL, stop_reason = NULL, next_at = now() - interval '1 minute'
WHERE alert_id = $1
`, a2); err != nil {
t.Fatal(err)
}
// Драбину штучно «воскресили» повз зупинку — рішення все одно має
// зупинити її, бо стан алерту читається перед кожною сходинкою.
dueClosed, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatal(err)
}
var found bool
for _, s := range dueClosed {
if s.AlertID != a2 {
continue
}
found = true
if got := PlanEscalation(s, time.Now()); got.Action != EscStop || got.Outcome != "closed" {
t.Fatalf("закритий алерт: %v/%s", got.Action, got.Outcome)
}
}
if !found {
t.Fatal("тест не дістав сходинку закритого алерту — перевірка нічого не довела")
}
}