Netpulse_SasS/server/internal/store/alerts_escalation_db_test.go
byrsapty ae07bd2a79
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m21s
CI / server (push) Successful in 1m48s
CI / agent (push) Successful in 2m59s
Прив'язка Telegram: сторінка була, дороги до неї не було
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не
давала виходу. У базі нуль прив'язок і нуль кодів за весь час.

Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я
користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як
/me віддавало лише пошту. В облікового запису власника, який заводить
установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій
інсталяції входу в профіль не було взагалі.

* /me віддає username (тип Me на фронтенді його вже вимагав);
* вхід у профіль малюється завжди для людини;
* пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим;
* текст бота називає те, що видно на екрані;
* сторінка каналів показує стан прив'язки біля telegram-каналу.

Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія
переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму
сходинку паралельно з першим. Тепер запис проходить лише за збігу
токена; при розбіжності не відбувається нічого, сходинка лишається
належною.

65 міграцій, усе зелене проти справжньої бази.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:57:09 +03:00

572 lines
23 KiB
Go
Raw Permalink 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("сходинка не повернулась у чергу — драбину нікому зупинити")
}
// --- Оренда: токен, а не строк ------------------------------------
//
// Розрив, якого не закривала сама лише leased_until. Партія — до
// сотні сходинок, кожна доставка з власним мережевим таймаутом, а
// оренда — дві хвилини: партія переростає її на першому ж підвислому
// каналі. Тоді рядок дістається наступному взяттю ще ДО того, як
// руки дійшли до нашої сходинки, і без звірки володіння обидва
// доставили б ту саму сходинку — два дзвінки о третій ночі й два
// рядки «надіслано» на одну сходинку.
//
// Нижче відтворено рівно цей порядок і перевірено три речі, кожна з
// яких могла б піти не так окремо.
// (1) Оренда спливла, але НІХТО не перебрав — запис має пройти.
//
// Це не дрібниця й не піддавки: якби токен вимагав ще й живого
// строку, кожна повільна партія втрачала б усі свої сходинки, і
// драбина повзла б зі швидкістю одного такту на сходинку. Токен
// доводить володіння, а володіння не зникає від того, що годинник
// пробив, поки на рядок ніхто інший не претендує.
a5 := newAlert(ruleID + ":dev:" + deviceID + ":5")
if err := st.ArmEscalation(ctx, tenantID, a5, 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`,
a5); err != nil {
t.Fatal(err)
}
// takeOne бере чергу й дістає з неї знімок потрібного алерту.
takeOne := func(alertID string) EscalationSnapshot {
t.Helper()
batch, err := st.TakeDueEscalations(ctx, 10)
if err != nil {
t.Fatalf("взяття черги: %v", err)
}
for _, x := range batch {
if x.AlertID == alertID {
return x
}
}
t.Fatalf("сходинка алерту %s не потрапила в чергу", alertID)
return EscalationSnapshot{}
}
// expireLease старить оренду, НЕ чіпаючи токена: саме так виглядає
// рядок, коли процес ще йде по партії, а строк уже вийшов.
expireLease := func(alertID string) {
t.Helper()
if _, err := st.pool.Exec(ctx,
`UPDATE alr.alert_escalations SET leased_until = now() - interval '1 second'
WHERE alert_id = $1`, alertID); err != nil {
t.Fatal(err)
}
}
// ladder читає стан драбини так, як його бачить база.
ladder := func(alertID string) (stepIdx int, token string, nextAt *time.Time) {
t.Helper()
if err := st.pool.QueryRow(ctx, `
SELECT step_idx, COALESCE(lease_token::text,''), next_at
FROM alr.alert_escalations WHERE alert_id = $1
`, alertID).Scan(&stepIdx, &token, &nextAt); err != nil {
t.Fatal(err)
}
return stepIdx, token, nextAt
}
sLate := takeOne(a5)
if sLate.LeaseToken == "" {
t.Fatal("взяття не поставило токен оренди — звіряти буде нічого")
}
expireLease(a5)
dLate := PlanEscalation(sLate, time.Now())
if dLate.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dLate.Action, dLate.Outcome)
}
okLate, err := st.ApplyEscalation(ctx, sLate, dLate)
if err != nil {
t.Fatal(err)
}
if !okLate {
t.Error("запис не пройшов, хоча оренду ніхто не перебирав — " +
"повільна партія втрачатиме всі свої сходинки")
}
// (2) Оренду ПЕРЕБРАЛИ — запізнілий запис не має пройти.
a6 := newAlert(ruleID + ":dev:" + deviceID + ":6")
if err := st.ArmEscalation(ctx, tenantID, a6, 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`,
a6); err != nil {
t.Fatal(err)
}
first := takeOne(a6) // перший інстанс: узяв і пішов доставляти партію
expireLease(a6) // партія затягнулась довше за оренду
second := takeOne(a6) // другий інстанс перебрав ту саму сходинку
if first.LeaseToken == second.LeaseToken {
t.Fatal("два взяття отримали однаковий токен — перевірка нічого не доводить")
}
if first.StepIdx != second.StepIdx {
t.Fatalf("другому взяттю дісталась інша сходинка (%d проти %d) — "+
"відтворено не той розрив", second.StepIdx, first.StepIdx)
}
dFirst := PlanEscalation(first, time.Now())
if dFirst.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dFirst.Action, dFirst.Outcome)
}
okFirst, err := st.ApplyEscalation(ctx, first, dFirst)
if err != nil {
t.Fatal(err)
}
if okFirst {
t.Error("запис пройшов під перебраною орендою — сходинка піде в канали вдруге")
}
// Відмова має бути БЕЗ наслідків: чужий запис не зриває ані оренду
// того, хто зараз працює, ані стан драбини. Інакше запізнілий
// інстанс, нічого не доставивши, псував би роботу тому, хто доставить.
stepAfter, tokenAfter, nextAfter := ladder(a6)
if stepAfter != first.StepIdx {
t.Errorf("невдалий запис усе-таки просунув драбину: step_idx = %d", stepAfter)
}
if tokenAfter != second.LeaseToken {
t.Errorf("невдалий запис зірвав оренду того, хто працює: токен %q", tokenAfter)
}
if nextAfter == nil {
t.Error("невдалий запис зняв час наступної сходинки — драбина осиротіла")
}
// (3) І драбина не застрягла.
//
// Найгірший розклад: другий інстанс, який перебрав сходинку, теж не
// дійшов до запису (впав, викотили нову версію). Тоді сходинка не
// доставлена НІКИМ, і єдине, що має її врятувати, — спливання
// оренди. Перевіряємо, що наступний такт бере її з тим самим
// step_idx і доводить до розв'язку.
expireLease(a6)
third := takeOne(a6)
if third.LeaseToken == second.LeaseToken || third.LeaseToken == first.LeaseToken {
t.Error("нове взяття не оновило токен — оренда перестала бути власністю")
}
if third.StepIdx != first.StepIdx {
t.Errorf("сходинку загублено: наступний такт узяв step_idx = %d замість %d",
third.StepIdx, first.StepIdx)
}
dThird := PlanEscalation(third, time.Now())
if dThird.Action != EscFire {
t.Fatalf("сходинка мала спрацювати: %v/%s", dThird.Action, dThird.Outcome)
}
okThird, err := st.ApplyEscalation(ctx, third, dThird)
if err != nil {
t.Fatal(err)
}
if !okThird {
t.Fatal("наступний такт не зміг записати рішення — драбина застрягла назавжди")
}
stepDone, tokenDone, nextDone := ladder(a6)
if stepDone != first.StepIdx+1 {
t.Errorf("драбина не просунулась: step_idx = %d", stepDone)
}
if tokenDone != "" {
t.Errorf("токен не знято разом з орендою: %q", tokenDone)
}
if nextDone == nil {
t.Error("у живої драбини не лишилось часу наступної сходинки")
}
// І найпізніший з усіх: перший інстанс, який усе ще тримає свій
// протухлий знімок, після цього теж нічого не запише.
okStale, err := st.ApplyEscalation(ctx, first, dFirst)
if err != nil {
t.Fatal(err)
}
if okStale {
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("тест не дістав сходинку закритого алерту — перевірка нічого не довела")
}
}