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) } }