axe/internal/notify/notify_test.go

483 lines
16 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
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())
}
}