Netpulse_SasS/server/internal/store/alerts_escalation_channels_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

166 lines
8.7 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 (
"errors"
"strings"
"testing"
)
// Перевірки сходинки, яка посилається на канал.
//
// Ці тести описують одну ваду й одну її ціну. Драбина зберігалась із
// будь-яким рядком у channel_ids — хоч із UUID видаленого каналу, хоч
// із UUID каналу чужого кабінету. Форма показувала таку сходинку
// налаштованою; движок не знаходив для неї жодного каналу, списував її
// й ішов далі. Тобто драбина існувала, виглядала робочою і не будила
// нікого — рівно той стан, від якого ескалацію й заводили.
//
// ValidateEscalationSteps перевіряє форму драбини й не має доступу до
// бази; ValidateStepChannels перевіряє належність каналів і отримує
// перелік готовим. Розділені саме тому, що перша половина має лишитись
// перевіреною без Postgres — а без другої перша дає хибну впевненість.
func knownChannels(ids ...string) map[string]bool {
m := map[string]bool{}
for _, id := range ids {
m[id] = true
}
return m
}
// Головний випадок: канал, якого в кабінеті немає.
func TestStepChannelUnknownRefused(t *testing.T) {
steps := []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
{AfterMin: 45, ChannelIDs: []string{"ch-lead", "ch-gone"}},
}
err := ValidateStepChannels(steps, knownChannels("ch-duty", "ch-lead"))
if err == nil {
t.Fatal("драбина з неіснуючим каналом збереглася — вона нікого не розбудить")
}
if !errors.Is(err, ErrInvalid) {
t.Fatalf("відмова має бути ErrInvalid (інакше HTTP віддасть 500): %v", err)
}
// Відмова без номера сходинки марна: у драбині їх до десяти, і
// «щось не так із каналами» не каже людині, що саме виправляти.
if !strings.Contains(err.Error(), "сходинка 2") {
t.Fatalf("відмова не називає сходинку: %q", err)
}
}
// Дзеркальний випадок: усе на місці — відмови бути не має.
func TestStepChannelsKnownAccepted(t *testing.T) {
steps := []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
{AfterMin: 45, ChannelIDs: []string{"ch-duty", "ch-lead"}},
}
if err := ValidateStepChannels(steps, knownChannels("ch-duty", "ch-lead", "ch-boss")); err != nil {
t.Fatalf("справна драбина не збереглася: %v", err)
}
}
// Канал чужого кабінету — той самий випадок, і це головне в ньому.
//
// Перевірка не знає слова «чужий»: їй дають перелік каналів ЦЬОГО
// кабінету, прочитаний під RLS у транзакції запису. Тому підставлений
// UUID сусіда не проходить не як окремий випадок, а як частина
// загального правила — і його не можна забути закрити окремо.
func TestStepChannelFromOtherTenantRefused(t *testing.T) {
const foreign = "00000000-0000-4000-8000-000000000001"
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{foreign}}}
err := ValidateStepChannels(steps, knownChannels("ch-duty"))
if err == nil {
t.Fatal("канал чужого кабінету прийнято в сходинку")
}
if !strings.Contains(err.Error(), foreign) {
t.Fatalf("відмова не називає ідентифікатор: %q", err)
}
}
// Вимкнений канал — не привід відмовляти.
//
// Перелік каналів кабінету не фільтрується за enabled навмисно:
// «вимкнув Telegram на час переїзду» не має ламати збереження драбини,
// у якій він стоїть. Про вимкнений канал говорить форма, і це інша
// розмова, ніж «такого каналу немає».
func TestStepChannelDisabledStillValid(t *testing.T) {
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch-off"}}}
if err := ValidateStepChannels(steps, knownChannels("ch-off")); err != nil {
t.Fatalf("вимкнений канал відхилено: %v", err)
}
}
// --- Чистка сходинок при видаленні каналу ----------------------------
//
// Друга з підтверджених вад: видалення каналу не чіпало сходинок, що на
// нього посилались. Зовнішнього ключа на масив усередині JSONB немає,
// тож ON DELETE SET NULL, яким 0066 прибирає драбину з правила, тут не
// спрацьовує — і в сходинці лишався UUID каналу, якого вже немає.
func TestRemoveChannelKeepsEmptyStep(t *testing.T) {
steps := []EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-gone"}},
{AfterMin: 45, ChannelIDs: []string{"ch-gone", "ch-lead"}},
}
got, changed := removeChannelFromSteps(steps, "ch-gone")
if !changed {
t.Fatal("посилання на видалений канал лишилось у драбині")
}
// Сходинка лишається на місці порожньою. Викинута зникла б
// безслідно й мовчки зсунула б усе чергування нижче: «через 45» на
// другій сходинці стало б першим підйомом, якого ніхто не просив.
if len(got) != 2 {
t.Fatalf("драбину зсунуто: %d сходинок замість 2", len(got))
}
if len(got[0].ChannelIDs) != 0 {
t.Fatalf("сходинка 1 мала лишитись порожньою: %v", got[0].ChannelIDs)
}
if got[0].AfterMin != 15 || got[1].AfterMin != 45 {
t.Fatalf("хвилини сходинок змінились: %+v", got)
}
if len(got[1].ChannelIDs) != 1 || got[1].ChannelIDs[0] != "ch-lead" {
t.Fatalf("сходинка 2 втратила чужий канал: %v", got[1].ChannelIDs)
}
}
// Драбина, яка каналу не знає, не має переписуватись.
//
// Не заради швидкості: зайвий UPDATE зсунув би updated_at і в переліку
// правок виглядав би як зміна чергування, якої не було.
func TestRemoveChannelUntouchedLadder(t *testing.T) {
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: []string{"ch-duty"}}}
got, changed := removeChannelFromSteps(steps, "ch-gone")
if changed {
t.Fatal("драбину без цього каналу оголошено зміненою")
}
if len(got[0].ChannelIDs) != 1 {
t.Fatalf("канали чужої драбини змінились: %v", got[0].ChannelIDs)
}
}
// Драбина називається один раз, скільки б сходинок у неї не вело в цей
// канал: перед видаленням цікаво, ЩО зламається, а не скільки разів
// воно згадане.
func TestLadderChannelIDsDeduplicated(t *testing.T) {
ids := ladderChannelIDs([]EscalationStep{
{AfterMin: 15, ChannelIDs: []string{"ch-duty"}},
{AfterMin: 45, ChannelIDs: []string{"ch-duty", "ch-lead"}},
})
if len(ids) != 2 || ids[0] != "ch-duty" || ids[1] != "ch-lead" {
t.Fatalf("перелік каналів драбини: %v", ids)
}
}
// Порожня сходинка лишається справою ValidateEscalationSteps.
//
// Тут вона проходить — і має проходити: дві перевірки не мають
// дублювати одна одну, інакше повідомлення розійдуться.
func TestStepChannelsEmptyStepIsOtherCheck(t *testing.T) {
steps := []EscalationStep{{AfterMin: 15, ChannelIDs: nil}}
if err := ValidateStepChannels(steps, knownChannels()); err != nil {
t.Fatalf("порожню сходинку має ловити ValidateEscalationSteps, а не ця перевірка: %v", err)
}
if err := ValidateEscalationSteps(steps); err == nil {
t.Fatal("порожню сходинку не спіймала жодна перевірка")
}
}