За другою рецензією:
* scripts/dbtest.sh писав у шапці «не напрямляйте на робочу базу» й
нічого для цього не робив — перевірено, пішов котити міграції на базу
з бойовим іменем. Тепер вимагає probe/test в імені.
* sendText ковтав помилку, тож журнал ескалацій писав «надіслано» на
сходинці, жодне повідомлення якої не дійшло. Три результати замість
двох: no_channels, failed, sent.
* stopped_at IS NULL рятував лише від ack; гасіння правилом і
ResolveMissing рядка драбини не чіпають, і сходинка дзвонила за
погашеним алертом. Додано перевірку стану алерту в тому ж UPDATE.
* escalate() блокував весь тік движка — мертвий вебхук одного кабінету
зупиняв обчислення правил усім. Винесено в RunEscalations.
* алерт, народжений під заглушенням, не сповіщався ніколи: ні при
народженні, ні коли вікно скінчилось. Тепер перехід suppressed→firing
сповіщається, а драбина рахує час від першого сповіщення.
* alr.rules.channel_ids приймав чужі канали, глушачи і сповіщення, і
драбину. Перевірка як для сходинок; DeleteChannel чистить посилання.
І перше, що зловив прогін проти справжньої бази: nil-зріз каналів їде
явним NULL повз DEFAULT '{}' — правило без каналів давало 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
5.4 KiB
Go
93 lines
5.4 KiB
Go
package store
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// Перевірки правила, яке шле в канал.
|
||
//
|
||
// Та сама вада, що вже закрита для сходинок драбини, тільки дорожча.
|
||
// alr.rules.channel_ids має тип uuid[], зовнішнього ключа на масив у
|
||
// Postgres немає — тож у правило лягав будь-який UUID: хоч вигаданий,
|
||
// хоч підставлений із сусіднього кабінету. У формі таке правило
|
||
// виглядало налаштованим на канал.
|
||
//
|
||
// Ціна: targets() для правила з явно вказаними каналами маршрути вже не
|
||
// питає, жодного каналу за мертвим id не знаходить, sent лишається
|
||
// нулем — і через нуль shouldArmEscalation не взводить навіть драбину.
|
||
// Тобто одна помилка в ідентифікаторі глушить і сповіщення, і резервний
|
||
// механізм, заведений рівно на випадок, коли сповіщення не спрацювало.
|
||
|
||
// Головний випадок: канал, якого в кабінеті немає.
|
||
func TestRuleChannelUnknownRefused(t *testing.T) {
|
||
err := ValidateRuleChannels([]string{"ch-duty", "ch-gone"}, 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(), "ch-gone") {
|
||
t.Fatalf("відмова не називає ідентифікатор: %q", err)
|
||
}
|
||
// І де взяти правильний. Відмова, яка лише каже «ні», змушує людину
|
||
// здогадуватись — саме цього уникає формулювання сходинок драбини.
|
||
if !strings.Contains(err.Error(), "Канали") {
|
||
t.Fatalf("відмова не підказує, де взяти правильний канал: %q", err)
|
||
}
|
||
}
|
||
|
||
// Дзеркальний випадок: усе на місці — відмови бути не має.
|
||
func TestRuleChannelsKnownAccepted(t *testing.T) {
|
||
if err := ValidateRuleChannels([]string{"ch-duty", "ch-lead"},
|
||
knownChannels("ch-duty", "ch-lead", "ch-boss")); err != nil {
|
||
t.Fatalf("справне правило не збереглося: %v", err)
|
||
}
|
||
}
|
||
|
||
// Канал чужого кабінету — той самий випадок, і це головне в ньому.
|
||
//
|
||
// Перевірка не знає слова «чужий»: їй дають перелік каналів ЦЬОГО
|
||
// кабінету, прочитаний під RLS у транзакції запису. Тому підставлений
|
||
// UUID сусіда не проходить не як окремий випадок, а як частина
|
||
// загального правила — і його не можна забути закрити окремо.
|
||
func TestRuleChannelFromOtherTenantRefused(t *testing.T) {
|
||
const foreign = "00000000-0000-4000-8000-000000000002"
|
||
err := ValidateRuleChannels([]string{foreign}, knownChannels("ch-duty"))
|
||
if err == nil {
|
||
t.Fatal("канал чужого кабінету прийнято в правило")
|
||
}
|
||
if !strings.Contains(err.Error(), foreign) {
|
||
t.Fatalf("відмова не називає ідентифікатор: %q", err)
|
||
}
|
||
}
|
||
|
||
// Порожній перелік — не помилка, а типовий стан правила.
|
||
//
|
||
// Порожньо означає «за загальними маршрутами кабінету»; відмовляти тут
|
||
// означало б вимагати канал у кожного правила, тобто зламати всі наявні
|
||
// однією перевіркою.
|
||
func TestRuleChannelsEmptyAllowed(t *testing.T) {
|
||
if err := ValidateRuleChannels(nil, knownChannels()); err != nil {
|
||
t.Fatalf("правило без каналів відхилено: %v", err)
|
||
}
|
||
if err := ValidateRuleChannels([]string{}, knownChannels("ch-duty")); err != nil {
|
||
t.Fatalf("порожній перелік каналів відхилено: %v", err)
|
||
}
|
||
}
|
||
|
||
// Вимкнений канал — не привід відмовляти, як і в драбині.
|
||
//
|
||
// Перелік каналів кабінету не фільтрується за enabled навмисно:
|
||
// «вимкнув Telegram на час переїзду» не має ламати збереження правила,
|
||
// у якому він стоїть. Про вимкнений канал говорить форма, і це інша
|
||
// розмова, ніж «такого каналу немає».
|
||
func TestRuleChannelDisabledStillValid(t *testing.T) {
|
||
if err := ValidateRuleChannels([]string{"ch-off"}, knownChannels("ch-off")); err != nil {
|
||
t.Fatalf("вимкнений канал відхилено: %v", err)
|
||
}
|
||
}
|