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", "высокий", `Открыть заявку`, } { 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, "<Ленина>") { t.Errorf("want escaped address:\n%s", text) } if !strings.Contains(text, "&") { 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()) } }