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

336 lines
12 KiB
Go
Raw 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 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)
}
}