Netpulse_SasS/server/internal/store/alerts_escalation_db_test.go
byrsapty cedf1d261d
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m50s
CI / agent (push) Successful in 1m2s
Ескалації: журнал більше не бреше, ack не воскрешає драбину
Три вади, знайдені рецензією, яких щасливий шлях показати не міг:

* outcome='sent' писався до доставки; помилка читання каналів клала в
  кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі
  слідом «надіслано». Канали тепер читаються до просування стану,
  журнал пишеться після доставки, з правдою.
* UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред
  партії не рятувало людину від дзвінка.
* час брався раз на партію.

Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер
домислював enabled на оновленні, channel_ids сходинок не звірялись із
каналами кабінету (зокрема чужого).

І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись.

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

312 lines
12 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)
}
// --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ----
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("тест не дістав сходинку закритого алерту — перевірка нічого не довела")
}
}