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