483 lines
16 KiB
Go
483 lines
16 KiB
Go
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, "<Ленина>") {
|
||
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())
|
||
}
|
||
}
|