axe/internal/repository/telegram_test.go

336 lines
12 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
package repository
import (
"errors"
"testing"
"time"
"building-mgmt/internal/models"
"building-mgmt/internal/telegram"
)
// Тесты темы «куда слать» (миграция 0011): цель в профиле, одноразовые
// коды привязки и выборка заявок-кандидатов. Интеграционные: живая БД
// через DATABASE_URL, без неё — пропуск.
// TestTelegramTarget — цель уведомлений: пусто по умолчанию, запись,
// очистка, отсутствующий профиль.
func TestTelegramTarget(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewTelegramRepo(q)
p := createProfile(t, ctx, q, "client")
got, err := repo.Target(ctx, p.ID)
if err != nil {
t.Fatalf("target: %v", err)
}
if got != "" {
t.Errorf("target = %q, want empty", got)
}
if err := repo.SetTarget(ctx, p.ID, "@buildings_group"); err != nil {
t.Fatalf("set target: %v", err)
}
if got, err = repo.Target(ctx, p.ID); err != nil || got != "@buildings_group" {
t.Errorf("target = %q, err = %v", got, err)
}
// Очистка — пустой строкой; читается как «не настроено».
if err := repo.SetTarget(ctx, p.ID, ""); err != nil {
t.Fatalf("clear target: %v", err)
}
if got, err = repo.Target(ctx, p.ID); err != nil || got != "" {
t.Errorf("target after clear = %q, err = %v", got, err)
}
// Сессия могла пережить удаление профиля — это не найдено, не 500.
if _, err := repo.Target(ctx, "00000000-0000-0000-0000-000000000000"); !errors.Is(err, ErrNotFound) {
t.Errorf("target for missing user: err = %v, want ErrNotFound", err)
}
if err := repo.SetTarget(ctx, "00000000-0000-0000-0000-000000000000", "@g"); !errors.Is(err, ErrNotFound) {
t.Errorf("set for missing user: err = %v, want ErrNotFound", err)
}
}
// TestTelegramBindCodes — одноразовость, гашение прежних, срок, чистка.
func TestTelegramBindCodes(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewTelegramRepo(q)
p := createProfile(t, ctx, q, "client")
const ttl = 15 * time.Minute
// --- выпуск и погашение ---
hash := telegram.CodeHash("CODEONE1")
expires, err := repo.CreateBindCode(ctx, p.ID, hash, ttl)
if err != nil {
t.Fatalf("create code: %v", err)
}
if !expires.After(time.Now().Add(ttl - time.Minute)) {
t.Errorf("expires = %v, want ~%v from now", expires, ttl)
}
userID, err := repo.ConsumeBindCode(ctx, hash)
if err != nil {
t.Fatalf("consume: %v", err)
}
if userID != p.ID {
t.Errorf("user = %s, want %s", userID, p.ID)
}
// Код одноразовый.
if _, err := repo.ConsumeBindCode(ctx, hash); !errors.Is(err, ErrNotFound) {
t.Errorf("reuse: err = %v, want ErrNotFound", err)
}
// Неизвестный код — та же ошибка, без подсказок.
if _, err := repo.ConsumeBindCode(ctx, telegram.CodeHash("NOSUCHCD")); !errors.Is(err, ErrNotFound) {
t.Errorf("unknown: err = %v, want ErrNotFound", err)
}
// --- выпуск нового гасит прежний: живёт ровно один ---
first := telegram.CodeHash("CODETWO1")
second := telegram.CodeHash("CODETHRE")
if _, err := repo.CreateBindCode(ctx, p.ID, first, ttl); err != nil {
t.Fatalf("first code: %v", err)
}
if _, err := repo.CreateBindCode(ctx, p.ID, second, ttl); err != nil {
t.Fatalf("second code: %v", err)
}
if _, err := repo.ConsumeBindCode(ctx, first); !errors.Is(err, ErrNotFound) {
t.Errorf("superseded code: err = %v, want ErrNotFound", err)
}
if userID, err = repo.ConsumeBindCode(ctx, second); err != nil || userID != p.ID {
t.Errorf("fresh code: user = %s, err = %v", userID, err)
}
// --- просроченный не работает (реально 15 минут не ждём) ---
expired := telegram.CodeHash("CODEFOUR")
if _, err := repo.CreateBindCode(ctx, p.ID, expired, ttl); err != nil {
t.Fatalf("expired code: %v", err)
}
if _, err := q.Exec(ctx, `
UPDATE telegram_bind_codes
SET created_at = now() - interval '2 hour',
expires_at = now() - interval '1 minute'
WHERE code_hash = $1`, expired); err != nil {
t.Fatalf("age code: %v", err)
}
if _, err := repo.ConsumeBindCode(ctx, expired); !errors.Is(err, ErrNotFound) {
t.Errorf("expired: err = %v, want ErrNotFound", err)
}
// --- чистка: протухшие строки уходят, свежие остаются ---
fresh := telegram.CodeHash("CODEFIVE")
if _, err := repo.CreateBindCode(ctx, p.ID, fresh, ttl); err != nil {
// Прежний неиспользованный (expired) гасится новым выпуском,
// поэтому здесь проверяем именно удаление устаревшего.
t.Fatalf("fresh code: %v", err)
}
// Возвращаем устаревшую строку: удаляли бы её сейчас — нечего было бы чистить.
if _, err := q.Exec(ctx, `
INSERT INTO telegram_bind_codes (user_id, code_hash, created_at, expires_at)
VALUES ($1, $2, now() - interval '2 hour', now() - interval '1 hour')
ON CONFLICT (code_hash) DO NOTHING`,
p.ID, telegram.CodeHash("CODEOLDX")); err != nil {
t.Fatalf("insert stale: %v", err)
}
n, err := repo.CleanupBindCodes(ctx, time.Now())
if err != nil {
t.Fatalf("cleanup: %v", err)
}
if n < 1 {
t.Errorf("cleanup removed %d, want >= 1", n)
}
// Свежий код чистке не достаётся.
if _, err := repo.ConsumeBindCode(ctx, fresh); err != nil {
t.Errorf("fresh code after cleanup: err = %v", err)
}
}
// TestTelegramBotToken — собственный токен бота (миграция 0012): запись
// зашифрованного значения, статус для API, список для загрузки при
// старте, очистка, отсутствующий профиль.
func TestTelegramBotToken(t *testing.T) {
q, ctx := newTestQuerier(t)
repo := NewTelegramRepo(q)
p := createProfile(t, ctx, q, "client")
// Токена нет — статус это показывает, списка для старта нет.
has, name, err := repo.BotTokenInfo(ctx, p.ID)
if err != nil || has || name != "" {
t.Fatalf("initial: has=%v name=%q err=%v", has, name, err)
}
list, err := repo.BotTokens(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
for _, u := range list {
if u.UserID == p.ID {
t.Errorf("fresh list contains user %s", p.ID)
}
}
// Сохранение: значение не трогаем (оно уже зашифровано снаружи),
// имя бота ложится рядом.
if err := repo.SetBotToken(ctx, p.ID, "enc-gcm-blob", "my_test_bot"); err != nil {
t.Fatalf("set: %v", err)
}
if has, name, err = repo.BotTokenInfo(ctx, p.ID); err != nil || !has || name != "my_test_bot" {
t.Errorf("after set: has=%v name=%q err=%v", has, name, err)
}
list, err = repo.BotTokens(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
found := false
for _, u := range list {
if u.UserID == p.ID {
found = true
if u.TokenEnc != "enc-gcm-blob" || u.Username != "my_test_bot" {
t.Errorf("list entry = %+v", u)
}
}
}
if !found {
t.Error("saved token not in BotTokens")
}
// Очистка: токена больше нет, строка исчезает и из списка.
if err := repo.ClearBotToken(ctx, p.ID); err != nil {
t.Fatalf("clear: %v", err)
}
if has, name, err = repo.BotTokenInfo(ctx, p.ID); err != nil || has || name != "" {
t.Errorf("after clear: has=%v name=%q err=%v", has, name, err)
}
list, err = repo.BotTokens(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
for _, u := range list {
if u.UserID == p.ID {
t.Errorf("cleared token still listed: %+v", u)
}
}
// Сессия могла пережить удаление профиля — это не найдено, не 500.
const missing = "00000000-0000-0000-0000-000000000000"
if _, _, err := repo.BotTokenInfo(ctx, missing); !errors.Is(err, ErrNotFound) {
t.Errorf("info for missing user: err = %v, want ErrNotFound", err)
}
if err := repo.SetBotToken(ctx, missing, "enc", "b"); !errors.Is(err, ErrNotFound) {
t.Errorf("set for missing user: err = %v, want ErrNotFound", err)
}
if err := repo.ClearBotToken(ctx, missing); !errors.Is(err, ErrNotFound) {
t.Errorf("clear for missing user: err = %v, want ErrNotFound", err)
}
}
// TestStaleTickets — кандидаты на уведомление и дедуп по tg_notified_at.
func TestStaleTickets(t *testing.T) {
q, ctx := newTestQuerier(t)
tg := NewTelegramRepo(q)
tickets := NewTicketRepo(q)
client := createProfile(t, ctx, q, "client")
worker := createProfile(t, ctx, q, "worker")
projectID, objectID, address := createProjectObject(t, ctx, q, client.ID)
tk := &models.Ticket{
ClientID: client.ID,
ProjectID: projectID,
ObjectID: objectID,
Address: address,
Floor: "3",
Category: "plumbing",
Description: "течёт кран",
Priority: "high",
}
if err := tickets.Create(ctx, tk); err != nil {
t.Fatalf("create ticket: %v", err)
}
cutoff := time.Now().Add(-24 * time.Hour)
// Без цели уведомлений слать некуда.
if got, err := tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
t.Fatalf("no target: got %d, err %v", len(got), err)
}
// Свежая заявка — не кандидат, даже с настроенной целью.
if err := tg.SetTarget(ctx, client.ID, "@buildings_group"); err != nil {
t.Fatalf("set target: %v", err)
}
if got, err := tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
t.Fatalf("fresh ticket: got %d, err %v", len(got), err)
}
// Старше суток — кандидат, все поля на месте.
stale := time.Now().Add(-25 * time.Hour)
if _, err := q.Exec(ctx,
`UPDATE tickets SET created_at = $2 WHERE id = $1`, tk.ID, stale); err != nil {
t.Fatalf("age ticket: %v", err)
}
got, err := tg.StaleTickets(ctx, cutoff, 10)
if err != nil {
t.Fatalf("stale: %v", err)
}
if len(got) != 1 {
t.Fatalf("stale: got %d, want 1", len(got))
}
s := got[0]
if s.ID != tk.ID || s.Target != "@buildings_group" ||
s.Category != "plumbing" || s.Priority != "high" ||
s.ClientName != client.Name || s.Address != address ||
s.ClientID != client.ID {
t.Errorf("candidate = %+v", s)
}
// Уведомление ушло — повтора нет (дедуп по tg_notified_at).
if err := tg.MarkNotified(ctx, tk.ID); err != nil {
t.Fatalf("mark: %v", err)
}
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
t.Fatalf("after mark: got %d, err %v", len(got), err)
}
// Сутки спустя — снова кандидат (имитируем прошедшее время).
if _, err := q.Exec(ctx, `
UPDATE tickets SET tg_notified_at = now() - interval '25 hours'
WHERE id = $1`, tk.ID); err != nil {
t.Fatalf("age notified_at: %v", err)
}
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 1 {
t.Fatalf("next day: got %d, err %v", len(got), err)
}
// Заявку взяли — напоминание неактуально: ни кандидатов, ни фиксации.
// Меняем как в репозитории: worker_id и status одной операцией плюс
// assigned_at (CHECK tickets_assigned_time).
if _, err := q.Exec(ctx, `
UPDATE tickets
SET status = 'assigned', worker_id = $2, assigned_at = now()
WHERE id = $1`, tk.ID, worker.ID); err != nil {
t.Fatalf("assign: %v", err)
}
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
t.Fatalf("assigned: got %d, err %v", len(got), err)
}
if err := tg.MarkNotified(ctx, tk.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("mark assigned: err = %v, want ErrNotFound", err)
}
// Выключенный аккаунт — не получает уведомлений.
if _, err := q.Exec(ctx, `
UPDATE profiles SET is_active = false WHERE id = $1`, client.ID); err != nil {
t.Fatalf("disable client: %v", err)
}
if _, err := q.Exec(ctx, `
UPDATE tickets
SET status = 'new', worker_id = NULL, assigned_at = NULL,
tg_notified_at = NULL
WHERE id = $1`, tk.ID); err != nil {
t.Fatalf("back to new: %v", err)
}
if got, err = tg.StaleTickets(ctx, cutoff, 10); err != nil || len(got) != 0 {
t.Fatalf("inactive client: got %d, err %v", len(got), err)
}
}