Три вади, знайдені рецензією, яких щасливий шлях показати не міг: * outcome='sent' писався до доставки; помилка читання каналів клала в кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі слідом «надіслано». Канали тепер читаються до просування стану, журнал пишеться після доставки, з правдою. * UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред партії не рятувало людину від дзвінка. * час брався раз на партію. Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер домислював enabled на оновленні, channel_ids сходинок не звірялись із каналами кабінету (зокрема чужого). І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
166 lines
8.7 KiB
Go
166 lines
8.7 KiB
Go
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("порожню сходинку не спіймала жодна перевірка")
|
||
}
|
||
}
|