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("тест не дістав сходинку закритого алерту — перевірка нічого не довела") } }