Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не давала виходу. У базі нуль прив'язок і нуль кодів за весь час. Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як /me віддавало лише пошту. В облікового запису власника, який заводить установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій інсталяції входу в профіль не було взагалі. * /me віддає username (тип Me на фронтенді його вже вимагав); * вхід у профіль малюється завжди для людини; * пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим; * текст бота називає те, що видно на екрані; * сторінка каналів показує стан прив'язки біля telegram-каналу. Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму сходинку паралельно з першим. Тепер запис проходить лише за збігу токена; при розбіжності не відбувається нічого, сходинка лишається належною. 65 міграцій, усе зелене проти справжньої бази. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
572 lines
23 KiB
Go
572 lines
23 KiB
Go
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("тест не дістав сходинку закритого алерту — перевірка нічого не довела")
|
||
}
|
||
}
|