axe/internal/notify/notify_test.go
2026-10-18 22:07:25 +03:00

483 lines
16 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 notify
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
"testing"
"time"
"building-mgmt/internal/repository"
"building-mgmt/internal/telegram"
)
// --- фейки -----------------------------------------------------------------
// fakeStore — база в памяти: записи вызовов нужны, чтобы видеть
// дедуп (MarkNotified) и гашение кодов.
type fakeStore struct {
items []repository.StaleTicket
staleErr error
marked []int64
markErr error
codes map[string]string // sha256 → user_id
targets map[string]string // user_id → chat_id
cleaned []time.Time
}
func newFakeStore() *fakeStore {
return &fakeStore{codes: map[string]string{}, targets: map[string]string{}}
}
func (f *fakeStore) StaleTickets(context.Context, time.Time, int) ([]repository.StaleTicket, error) {
if f.staleErr != nil {
return nil, f.staleErr
}
return f.items, nil
}
func (f *fakeStore) MarkNotified(_ context.Context, id int64) error {
if f.markErr != nil {
return f.markErr
}
f.marked = append(f.marked, id)
return nil
}
func (f *fakeStore) ConsumeBindCode(_ context.Context, hash string) (string, error) {
userID, ok := f.codes[hash]
if !ok {
return "", repository.ErrNotFound
}
delete(f.codes, hash) // одноразовость — на уровне фейка тоже
return userID, nil
}
func (f *fakeStore) SetTarget(_ context.Context, userID, target string) error {
f.targets[userID] = target
return nil
}
func (f *fakeStore) CleanupBindCodes(_ context.Context, before time.Time) (int64, error) {
f.cleaned = append(f.cleaned, before)
return 0, nil
}
// sentMsg — одно отправленное сообщение.
type sentMsg struct{ chat, text string }
// fakeBot — Telegram без сети: ошибки отправки задаются по чату,
// поллинг отдаёт заготовленные батчи, потом встаёт до остановки.
type fakeBot struct {
enabled bool
username string
sendErr map[string]error
sent []sentMsg
updates [][]telegram.Update
getErr error
idle chan struct{} // закрывается перед первым «висящим» вызовом
// stopped — закрывается, когда GetUpdates вышел по отмене контекста:
// по нему видно, что поллер реально остановился, а не висит.
stopped chan struct{}
mu sync.Mutex
offsets []int64
calls int
}
func (f *fakeBot) Enabled() bool { return f.enabled }
func (f *fakeBot) Username() string { return f.username }
func (f *fakeBot) Init(context.Context) error { return nil }
func (f *fakeBot) SendMessage(_ context.Context, chat, text string) error {
if err, ok := f.sendErr[chat]; ok {
return err
}
f.sent = append(f.sent, sentMsg{chat: chat, text: text})
return nil
}
func (f *fakeBot) GetChat(context.Context, string) error { return nil }
func (f *fakeBot) GetUpdates(ctx context.Context, offset int64, _ time.Duration) ([]telegram.Update, error) {
f.mu.Lock()
f.offsets = append(f.offsets, offset)
n := f.calls
f.calls++
f.mu.Unlock()
if f.getErr != nil && n == 0 {
return nil, f.getErr
}
if n < len(f.updates) {
return f.updates[n], nil
}
// Батчи кончились — «висим», как настоящий длинный поллинг.
if f.idle != nil && n == len(f.updates) {
close(f.idle)
}
<-ctx.Done()
if f.stopped != nil {
select {
case <-f.stopped:
default:
close(f.stopped)
}
}
return nil, ctx.Err()
}
func (f *fakeBot) seenOffsets() []int64 {
f.mu.Lock()
defer f.mu.Unlock()
return append([]int64(nil), f.offsets...)
}
// fakeBots — реализация Bots для тестов уведомлений: один запасной бот
// на всех (как раньше был глобальный) плюс личные по пользователю.
type fakeBots struct {
fallback telegram.API
byUser map[string]telegram.API
}
func (f *fakeBots) BotFor(userID string) telegram.API {
if b, ok := f.byUser[userID]; ok {
return b
}
return f.fallback
}
func testLog() *slog.Logger {
return slog.New(slog.DiscardHandler)
}
// --- уведомления -----------------------------------------------------------
// TestTickOnceNotifies — обе заявки ушли, текст собран, отправка
// зафиксирована (дедуп на следующих тиках), коды почищены.
func TestTickOnceNotifies(t *testing.T) {
store := newFakeStore()
store.items = []repository.StaleTicket{
{ID: 12, Address: "ул. Ленина, 5", Category: "plumbing",
Priority: "high", ClientName: "Иван", Target: "@buildings"},
{ID: 13, Address: "пр. Мира, 1", Category: "electric",
Priority: "normal", ClientName: "Иван", Target: "-100500"},
}
bot := &fakeBot{enabled: true, username: "test_bot"}
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://localhost:3000", testLog())
if len(bot.sent) != 2 {
t.Fatalf("sent = %d, want 2", len(bot.sent))
}
first := bot.sent[0]
if first.chat != "@buildings" {
t.Errorf("chat = %q, want @buildings", first.chat)
}
for _, want := range []string{
"Заявка №12", "без движения более 24 ч.", "сантехника",
"ул. Ленина, 5", "высокий",
`<a href="http://localhost:3000/#/tickets/12">Открыть заявку</a>`,
} {
if !strings.Contains(first.text, want) {
t.Errorf("message misses %q:\n%s", want, first.text)
}
}
if len(store.marked) != 2 || store.marked[0] != 12 || store.marked[1] != 13 {
t.Errorf("marked = %v, want [12 13]", store.marked)
}
if len(store.cleaned) == 0 {
t.Error("bind codes cleanup did not run")
}
}
// TestTickOnceSendFailure — временная ошибка (сеть): заявка остаётся
// кандидатом, повтор будет на следующем тике.
func TestTickOnceSendFailure(t *testing.T) {
store := newFakeStore()
store.items = []repository.StaleTicket{{ID: 1, Target: "@buildings"}}
bot := &fakeBot{enabled: true, sendErr: map[string]error{
"@buildings": errors.New("connection reset"),
}}
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://x", testLog())
if len(store.marked) != 0 {
t.Errorf("marked = %v, want none (ошибка временная)", store.marked)
}
}
// TestTickOncePermanentError — чат недоступен: повтор бессмыслен,
// помечаем отправленным, чтобы не долбить лог каждую минуту.
func TestTickOncePermanentError(t *testing.T) {
store := newFakeStore()
store.items = []repository.StaleTicket{{ID: 1, Target: "@gone"}}
bot := &fakeBot{enabled: true, sendErr: map[string]error{
"@gone": telegram.ErrChatNotFound,
}}
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://x", testLog())
if len(store.marked) != 1 {
t.Errorf("marked = %v, want [1]", store.marked)
}
}
// TestTickOnceStaleError — база недоступна: тихо выходим, ничего не шлём.
func TestTickOnceStaleError(t *testing.T) {
store := newFakeStore()
store.staleErr = errors.New("db down")
bot := &fakeBot{enabled: true}
tickOnce(context.Background(), store, &fakeBots{fallback: bot}, 24*time.Hour, "http://x", testLog())
if len(bot.sent) != 0 {
t.Errorf("sent = %d, want 0", len(bot.sent))
}
}
// TestTickOnceChoosesBot — у клиентов разные боты: сообщение уходит
// через личный токен, а без него — через запасной (глобальный).
func TestTickOnceChoosesBot(t *testing.T) {
store := newFakeStore()
store.items = []repository.StaleTicket{
{ID: 1, Target: "@for_mine", ClientID: "user-1"},
{ID: 2, Target: "@for_other", ClientID: "user-2"},
}
mine := &fakeBot{enabled: true, username: "mine_bot"}
global := &fakeBot{enabled: true, username: "global_bot"}
bots := &fakeBots{
fallback: global,
byUser: map[string]telegram.API{"user-1": mine},
}
tickOnce(context.Background(), store, bots, 24*time.Hour, "http://x", testLog())
if len(mine.sent) != 1 || mine.sent[0].chat != "@for_mine" {
t.Errorf("mine sent = %+v, want [@for_mine]", mine.sent)
}
if len(global.sent) != 1 || global.sent[0].chat != "@for_other" {
t.Errorf("global sent = %+v, want [@for_other]", global.sent)
}
if len(store.marked) != 2 {
t.Errorf("marked = %v, want обе", store.marked)
}
}
// TestTickOnceWithoutBot — ни своего бота, ни включённого глобального:
// слать нечем, заявка остаётся кандидатом — уведомление уйдёт, как
// только пользователь подключит бота, а не потеряется.
func TestTickOnceWithoutBot(t *testing.T) {
store := newFakeStore()
store.items = []repository.StaleTicket{{ID: 5, Target: "@x", ClientID: "user-1"}}
tickOnce(context.Background(), store, &fakeBots{fallback: &fakeBot{enabled: false}},
24*time.Hour, "http://x", testLog())
if len(store.marked) != 0 {
t.Errorf("marked = %v, want none (бота нет — не фиксируем)", store.marked)
}
}
// TestMessageEscapesHTML — пользовательский текст не ломает разметку
// (адрес с <, > и & — обычное дело).
func TestMessageEscapesHTML(t *testing.T) {
tk := repository.StaleTicket{ID: 7,
Address: `ул. <Ленина> & "дом"`,
Category: "other", Priority: "low"}
text := message(tk, 24*time.Hour, "http://localhost:3000")
if strings.Contains(text, "<Ленина>") {
t.Errorf("address is not escaped:\n%s", text)
}
if !strings.Contains(text, "&lt;Ленина&gt;") {
t.Errorf("want escaped address:\n%s", text)
}
if !strings.Contains(text, "&amp;") {
t.Errorf("want escaped ampersand:\n%s", text)
}
// Ссылка остаётся рабочей.
if !strings.Contains(text, `href="http://localhost:3000/#/tickets/7"`) {
t.Errorf("link broken:\n%s", text)
}
}
// TestDurationLabel — порог в сообщении: часы, либо минуты (< 1 ч).
func TestDurationLabel(t *testing.T) {
if got := durationLabel(24 * time.Hour); got != "24 ч." {
t.Errorf("24h = %q", got)
}
if got := durationLabel(48 * time.Hour); got != "48 ч." {
t.Errorf("48h = %q", got)
}
if got := durationLabel(30 * time.Minute); got != "30 мин." {
t.Errorf("30m = %q", got)
}
}
// --- поллер ----------------------------------------------------------------
// TestStartArg — разбор команд: /start, /start@bot, посторонний текст.
func TestStartArg(t *testing.T) {
for _, tc := range []struct {
text string
arg string
ok bool
}{
{"/start ABCD2345", "ABCD2345", true},
{"/start@my_bot ABCD2345", "ABCD2345", true},
{"/start", "", true},
{"/start ", "", true},
{"/Start abcd", "abcd", true}, // регистр не важен
{"/help", "", false},
{"привет", "", false},
{"", "", false},
{"/startx ABC", "", false},
} {
arg, ok := startArg(tc.text)
if arg != tc.arg || ok != tc.ok {
t.Errorf("startArg(%q) = (%q, %v), want (%q, %v)",
tc.text, arg, ok, tc.arg, tc.ok)
}
}
}
// TestHandleStartBinds — /start с валидным кодом: чат записан владельцу,
// человеку ответили подтверждением, код сгорел.
func TestHandleStartBinds(t *testing.T) {
const code = "ABCD2345"
store := newFakeStore()
store.codes[telegram.CodeHash(code)] = "user-1"
bot := &fakeBot{enabled: true, username: "test_bot"}
handleStart(context.Background(), store, bot, &telegram.Message{
Text: "/start " + code,
Chat: telegram.Chat{ID: 777, Type: "private"},
}, testLog())
if store.targets["user-1"] != "777" {
t.Errorf("targets = %v, want user-1 → 777", store.targets)
}
if len(bot.sent) != 1 || bot.sent[0].chat != "777" ||
bot.sent[0].text != boundText {
t.Errorf("sent = %+v, want подтверждение в 777", bot.sent)
}
// Код одноразовый: повторное сообщение — «не найден».
handleStart(context.Background(), store, bot, &telegram.Message{
Text: "/start " + code,
Chat: telegram.Chat{ID: 888, Type: "private"},
}, testLog())
if len(bot.sent) != 2 || bot.sent[1].text != codeBadText {
t.Errorf("second attempt: sent = %+v", bot.sent)
}
if _, ok := store.targets[""]; ok {
t.Error("target записан пустому пользователю")
}
}
// TestHandleStartIgnoresNoise — боту пишут что угодно: реагируем только
// на /start, помощь — только после чистого /start.
func TestHandleStartIgnoresNoise(t *testing.T) {
store := newFakeStore()
bot := &fakeBot{enabled: true}
for _, text := range []string{"", "привет", "/help", "/status"} {
handleStart(context.Background(), store, bot,
&telegram.Message{Text: text, Chat: telegram.Chat{ID: 5}},
testLog())
}
if len(bot.sent) != 0 {
t.Errorf("sent = %v, want none", bot.sent)
}
// /start без кода — подсказка, где взять код.
handleStart(context.Background(), store, bot,
&telegram.Message{Text: "/start", Chat: telegram.Chat{ID: 5}},
testLog())
if len(bot.sent) != 1 || bot.sent[0].text != helpText {
t.Errorf("sent = %+v, want подсказка", bot.sent)
}
}
// TestRunPollerOffsets — offset сдвигается всегда, включая сообщения,
// которые не наша команда: иначе одно такое встало бы в очередь навечно.
func TestRunPollerOffsets(t *testing.T) {
store := newFakeStore()
store.codes[telegram.CodeHash("ABCD2345")] = "user-1"
bot := &fakeBot{
enabled: true,
username: "test_bot",
idle: make(chan struct{}),
updates: [][]telegram.Update{{
{UpdateID: 1, Message: &telegram.Message{
Text: "/start ABCD2345", Chat: telegram.Chat{ID: 777}}},
{UpdateID: 2, Message: &telegram.Message{
Text: "спам", Chat: telegram.Chat{ID: 777}}},
}},
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
RunPoller(ctx, store, bot, testLog())
close(done)
}()
<-bot.idle // батч обработан, поллер ушёл в ожидание апдейтов
cancel()
<-done
if got, want := bot.seenOffsets(), []int64{0, 3}; len(got) != len(want) ||
got[0] != want[0] || got[1] != want[1] {
t.Errorf("offsets = %v, want %v", got, want)
}
if store.targets["user-1"] != "777" {
t.Errorf("targets = %v, want user-1 → 777", store.targets)
}
// Спам-сообщение проигнорировано (никаких ответов на него).
if len(bot.sent) != 1 || bot.sent[0].text != boundText {
t.Errorf("sent = %+v, want только подтверждение привязки", bot.sent)
}
}
// TestRunPollerBackoff — ошибка поллинга: сначала повтор, при остановке —
// выход без зависания.
func TestRunPollerBackoff(t *testing.T) {
bot := &fakeBot{enabled: true, getErr: errors.New("network down")}
store := newFakeStore()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
done := make(chan struct{})
go func() {
RunPoller(ctx, store, bot, testLog())
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("RunPoller не остановился по контексту")
}
if len(bot.seenOffsets()) == 0 {
t.Error("poller не пытался получить апдейты")
}
}
// TestRunPollerDisabled — без токена поллер не стартует (его и не должны
// запускать в cmd/api, но и тут — защита от дурака).
func TestRunPollerDisabled(t *testing.T) {
bot := &fakeBot{enabled: false, idle: make(chan struct{})}
RunPoller(context.Background(), newFakeStore(), bot, testLog())
if len(bot.seenOffsets()) != 0 {
t.Errorf("offsets = %v, want none", bot.seenOffsets())
}
}