Когда для распознавания сохранены кандидаты метабазы, карточка подтверждения бота показывает кнопку «🗂 База (N)». По ней двухшагово (как delete/dismiss) разворачивается список кандидатов inline-кнопками; выбор пиннит источник через worker.ChooseCandidate (ручной матч, без авто-раскладки) и обновляет карточку. Веб остаётся точкой точных правок (ручной ввод id/URL, «без базы»). Безопасность границы: id кандидата из callback_data валидируется как ULID (ident.Parse) до доменного вызова, как в вебе. Текст inline-кнопок Telegram не парсится как HTML — название кандидата в подписи не экранируется. SDD: change telegram-vybor-nahodok — дельта notifications (ADDED «Выбор кандидата метабазы из карточки подтверждения бота») + review (MODIFIED «Разделение труда транспортов»: быстрый выбор кандидата — Telegram-действие). Влито в specs, change заархивирован. Миграций БД нет (кандидаты уже в БД). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
572 lines
22 KiB
Go
572 lines
22 KiB
Go
package tgbot
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
|
"git.vakhrushev.me/av/jellybit/internal/layout"
|
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
)
|
|
|
|
// fakeAPI записывает исходящие Chattable; обновления не нужны (хендлеры зовём
|
|
// напрямую).
|
|
type fakeAPI struct {
|
|
sent []sentMsg
|
|
edits []sentMsg
|
|
answers []string
|
|
fileURL string // GetFileDirectURL возвращает это (для .torrent-документов)
|
|
fileErr error
|
|
}
|
|
|
|
type sentMsg struct {
|
|
chatID int64
|
|
text string
|
|
hasKB bool
|
|
parseMode string
|
|
}
|
|
|
|
func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
|
|
switch m := c.(type) {
|
|
case tgbotapi.MessageConfig:
|
|
f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
|
|
case tgbotapi.EditMessageTextConfig:
|
|
f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
|
|
}
|
|
return tgbotapi.Message{MessageID: 1}, nil
|
|
}
|
|
func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
|
|
if cb, ok := c.(tgbotapi.CallbackConfig); ok {
|
|
f.answers = append(f.answers, cb.Text)
|
|
}
|
|
return &tgbotapi.APIResponse{Ok: true}, nil
|
|
}
|
|
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
|
|
func (f *fakeAPI) StopReceivingUpdates() {}
|
|
func (f *fakeAPI) GetFileDirectURL(string) (string, error) { return f.fileURL, f.fileErr }
|
|
|
|
type fakeIngestor struct {
|
|
lastReq ingest.Request
|
|
res ingest.Result
|
|
}
|
|
|
|
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
|
|
f.lastReq = req
|
|
return f.res, nil
|
|
}
|
|
|
|
type fakeReviewer struct {
|
|
data *worker.ReviewData
|
|
applied []string
|
|
refined map[string]string
|
|
typed map[string]string
|
|
deferred []string
|
|
canceled []string
|
|
retried []string
|
|
deleted []string
|
|
dismissed []string
|
|
chosen map[string]string // downloadID → выбранный candidateID
|
|
}
|
|
|
|
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
|
|
return f.data, nil
|
|
}
|
|
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
|
|
f.applied = append(f.applied, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
|
|
if f.refined == nil {
|
|
f.refined = map[string]string{}
|
|
}
|
|
f.refined[id] = hint
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
|
|
if f.chosen == nil {
|
|
f.chosen = map[string]string{}
|
|
}
|
|
f.chosen[id] = candidateID
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
|
|
if f.typed == nil {
|
|
f.typed = map[string]string{}
|
|
}
|
|
f.typed[id] = t
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
|
|
f.deferred = append(f.deferred, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Cancel(_ context.Context, id string) error {
|
|
f.canceled = append(f.canceled, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Retry(_ context.Context, id string) error {
|
|
f.retried = append(f.retried, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
|
|
f.deleted = append(f.deleted, id)
|
|
return nil
|
|
}
|
|
func (f *fakeReviewer) Dismiss(_ context.Context, id string) error {
|
|
f.dismissed = append(f.dismissed, id)
|
|
return nil
|
|
}
|
|
|
|
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
|
|
const tid = "01arz3ndektsv4rrffq69g5fav"
|
|
|
|
func reviewData(state store.State) *worker.ReviewData {
|
|
s, e := 2, 1
|
|
return &worker.ReviewData{
|
|
Download: store.Download{ID: tid, State: state, DisplayName: "Фарго (2015). Сезон 2", Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
|
|
Recognition: &store.Recognition{
|
|
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
|
Reasons: `["неполный пак"]`,
|
|
},
|
|
// Эффективные provider/id (как заполняет effectiveProvider) — их и
|
|
// показывают уведомления/карточка, консистентно с веб-страницей.
|
|
Provider: "tvdb", ProviderID: "269613",
|
|
Plan: recognize.Plan{
|
|
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
|
|
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
|
|
},
|
|
Preview: []layout.Link{
|
|
{Src: "e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
|
|
t.Helper()
|
|
api := &fakeAPI{}
|
|
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
|
|
rev := &fakeReviewer{data: reviewData(store.StateReview)}
|
|
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
|
|
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
return b, api, ing, rev
|
|
}
|
|
|
|
func msgFrom(userID int64, text string) *tgbotapi.Message {
|
|
return &tgbotapi.Message{
|
|
MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID}, Text: text,
|
|
}
|
|
}
|
|
|
|
func TestBot_IngestFromMagnet(t *testing.T) {
|
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
|
b.handleMessage(context.Background(), msgFrom(7, "крутой сериал\nmagnet:?xt=urn:btih:ABC"))
|
|
|
|
if !strings.HasPrefix(ing.lastReq.Source, "magnet:?xt=urn:btih:ABC") {
|
|
t.Errorf("source = %q", ing.lastReq.Source)
|
|
}
|
|
if ing.lastReq.Context != "крутой сериал" {
|
|
t.Errorf("context = %q", ing.lastReq.Context)
|
|
}
|
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+idCode(tid)) {
|
|
t.Errorf("sent = %+v", api.sent)
|
|
}
|
|
}
|
|
|
|
// Дедуп на приёме (дубль на уже активную задачу) → явный ответ «дубль …
|
|
// добавление отменено», а не «Принято».
|
|
func TestBot_IngestDeduplicated(t *testing.T) {
|
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
|
ing.res = ingest.Result{DownloadID: tid, State: store.StateDownloading, Deduplicated: true}
|
|
|
|
b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))
|
|
|
|
if len(api.sent) != 1 {
|
|
t.Fatalf("sent = %+v", api.sent)
|
|
}
|
|
txt := api.sent[0].text
|
|
if !strings.Contains(txt, "Дубль") || !strings.Contains(txt, tid) || strings.Contains(txt, "Принято") {
|
|
t.Errorf("ожидалось сообщение о дубле с #%s, got %q", tid, txt)
|
|
}
|
|
}
|
|
|
|
// Дедуп на «спящую» desync-запись (target_missing) → сообщение зовёт привязать
|
|
// заново/закрыть, а не «дубль активной».
|
|
func TestBot_IngestDeduplicatedDesync(t *testing.T) {
|
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
|
ing.res = ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}
|
|
|
|
b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))
|
|
|
|
if len(api.sent) != 1 {
|
|
t.Fatalf("sent = %+v", api.sent)
|
|
}
|
|
txt := api.sent[0].text
|
|
if !strings.Contains(txt, "без цели") || !strings.Contains(txt, tid) {
|
|
t.Errorf("ожидалось сообщение о записи без цели с #%s, got %q", tid, txt)
|
|
}
|
|
}
|
|
|
|
func TestBot_DeniesUnknownUser(t *testing.T) {
|
|
b, api, ing, _ := newTestBot(t, []int64{7})
|
|
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
|
|
|
|
if len(ing.lastReq.Source) != 0 {
|
|
t.Error("ingest не должен вызываться для чужого пользователя")
|
|
}
|
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Доступ запрещён") {
|
|
t.Errorf("sent = %+v", api.sent)
|
|
}
|
|
}
|
|
|
|
func TestBot_NoMagnet(t *testing.T) {
|
|
b, api, _, _ := newTestBot(t, []int64{7})
|
|
b.handleMessage(context.Background(), msgFrom(7, "привет"))
|
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Не вижу magnet") {
|
|
t.Errorf("sent = %+v", api.sent)
|
|
}
|
|
}
|
|
|
|
func TestBot_RefineViaReply(t *testing.T) {
|
|
b, _, _, rev := newTestBot(t, []int64{7})
|
|
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
|
|
b.setPending(7, tid)
|
|
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
|
|
|
|
if rev.refined[tid] != "это второй сезон" {
|
|
t.Errorf("refine = %v", rev.refined)
|
|
}
|
|
}
|
|
|
|
func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
|
|
return &tgbotapi.CallbackQuery{
|
|
ID: "cb", From: &tgbotapi.User{ID: userID}, Data: data,
|
|
Message: &tgbotapi.Message{MessageID: 99, Chat: &tgbotapi.Chat{ID: userID}},
|
|
}
|
|
}
|
|
|
|
func TestBot_CallbackApply(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
|
|
|
|
if len(rev.applied) != 1 || rev.applied[0] != tid {
|
|
t.Errorf("applied = %v", rev.applied)
|
|
}
|
|
if len(api.answers) != 1 {
|
|
t.Errorf("answers = %v", api.answers)
|
|
}
|
|
if len(api.edits) != 1 { // карточка обновлена на месте
|
|
t.Errorf("edits = %v", api.edits)
|
|
}
|
|
if api.edits[0].parseMode != tgbotapi.ModeHTML {
|
|
t.Errorf("edit parse mode = %q, want HTML", api.edits[0].parseMode)
|
|
}
|
|
}
|
|
|
|
func TestBot_CallbackType(t *testing.T) {
|
|
b, _, _, rev := newTestBot(t, []int64{7})
|
|
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
|
|
if rev.typed[tid] != "movie" {
|
|
t.Errorf("typed = %v", rev.typed)
|
|
}
|
|
}
|
|
|
|
func TestBot_CallbackRefineSetsPending(t *testing.T) {
|
|
b, api, _, _ := newTestBot(t, []int64{7})
|
|
b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))
|
|
|
|
if id, ok := b.takePending(7); !ok || id != tid {
|
|
t.Errorf("pending = %s,%v", id, ok)
|
|
}
|
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
|
|
t.Errorf("sent = %+v", api.sent)
|
|
}
|
|
}
|
|
|
|
func TestBot_CallbackDeniesUnknown(t *testing.T) {
|
|
b, _, _, rev := newTestBot(t, []int64{7})
|
|
b.handleCallback(context.Background(), cbFrom(999, "apply:"+tid))
|
|
if len(rev.applied) != 0 {
|
|
t.Error("чужой колбэк не должен исполняться")
|
|
}
|
|
}
|
|
|
|
func TestBot_NotifyReview(t *testing.T) {
|
|
b, api, _, _ := newTestBot(t, []int64{7, 8})
|
|
b.Notify(context.Background(), tid, worker.EventReview)
|
|
|
|
if len(api.sent) != 2 { // обоим доверенным
|
|
t.Fatalf("sent to %d chats, want 2", len(api.sent))
|
|
}
|
|
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+idCode(tid)) {
|
|
t.Errorf("card text = %q", api.sent[0].text)
|
|
}
|
|
if !api.sent[0].hasKB {
|
|
t.Error("карточка ревью без клавиатуры")
|
|
}
|
|
}
|
|
|
|
func TestBot_NotifyDone(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
rev.data = reviewData(store.StateDone)
|
|
b.Notify(context.Background(), tid, worker.EventDone)
|
|
|
|
// Заголовок берётся из display_name (консистентно с веб-UI), а не из Plan.Title.
|
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") ||
|
|
!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") {
|
|
t.Errorf("sent = %+v", api.sent)
|
|
}
|
|
}
|
|
|
|
func TestBot_NotifyFailed(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
rev.data = reviewData(store.StateFailed)
|
|
b.Notify(context.Background(), tid, worker.EventFailed)
|
|
|
|
// В ошибке — и заголовок (display_name), и #id для поиска по логам.
|
|
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") ||
|
|
!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") ||
|
|
!strings.Contains(api.sent[0].text, "#"+idCode(tid)) {
|
|
t.Errorf("sent = %+v", api.sent)
|
|
}
|
|
if !api.sent[0].hasKB { // кнопка повтора
|
|
t.Error("уведомление о падении без клавиатуры повтора")
|
|
}
|
|
}
|
|
|
|
// HTML parse mode включён у всех исходящих: внешний текст (название, источник,
|
|
// причины) должен экранироваться, а download id — уходить моноширинным <code>.
|
|
// Иначе спецсимволы (`<`/`>`/`&`) в названии/пути сломали бы разметку.
|
|
func TestBot_NotifyEscapesExternalText(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
rd := reviewData(store.StateReview)
|
|
rd.Download.DisplayName = "" // чтобы карточка показала Plan.Title через guessLine
|
|
rd.Plan.Title = "Tom & Jerry <b>x</b>"
|
|
// Источник длиннее лимита shorten(80) со спецсимволом ровно на границе:
|
|
// проверяем, что esc идёт ПОСЛЕ усечения (иначе сущность разрубится).
|
|
rd.Download.Context = strings.Repeat("a", 79) + "&" + strings.Repeat("b", 5)
|
|
rev.data = rd
|
|
|
|
b.Notify(context.Background(), tid, worker.EventReview)
|
|
|
|
if len(api.sent) != 1 {
|
|
t.Fatalf("sent = %+v", api.sent)
|
|
}
|
|
msg := api.sent[0]
|
|
if msg.parseMode != tgbotapi.ModeHTML {
|
|
t.Errorf("parse mode = %q, want HTML", msg.parseMode)
|
|
}
|
|
// download id — моноширинным (tap-to-copy).
|
|
if !strings.Contains(msg.text, idCode(tid)) {
|
|
t.Errorf("id не в <code>: %q", msg.text)
|
|
}
|
|
// Спецсимволы названия экранированы, сырая разметка не просочилась.
|
|
if !strings.Contains(msg.text, "Tom & Jerry <b>x</b>") {
|
|
t.Errorf("название не экранировано: %q", msg.text)
|
|
}
|
|
if strings.Contains(msg.text, "<b>") {
|
|
t.Errorf("сырая разметка просочилась: %q", msg.text)
|
|
}
|
|
// Усечённый источник: сущность на границе цела (esc после shorten даёт
|
|
// «&…», а не разрубленное «&am…»).
|
|
if !strings.Contains(msg.text, "&…") {
|
|
t.Errorf("источник обрезан посреди сущности: %q", msg.text)
|
|
}
|
|
}
|
|
|
|
// Failed-путь (renderFailed): спецсимволы в названии и тексте ошибки
|
|
// экранируются, #id уходит моноширинным. Закрывает opErr/failed-ветку формата.
|
|
func TestBot_NotifyFailedEscapesExternalText(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
rd := reviewData(store.StateFailed)
|
|
rd.Download.DisplayName = "A & B <x>"
|
|
rd.Download.ErrorMsg = store.NullString("path <bad> & stuff")
|
|
rev.data = rd
|
|
|
|
b.Notify(context.Background(), tid, worker.EventFailed)
|
|
|
|
if len(api.sent) != 1 {
|
|
t.Fatalf("sent = %+v", api.sent)
|
|
}
|
|
msg := api.sent[0]
|
|
if msg.parseMode != tgbotapi.ModeHTML {
|
|
t.Errorf("parse mode = %q, want HTML", msg.parseMode)
|
|
}
|
|
if !strings.Contains(msg.text, "«A & B <x>»") {
|
|
t.Errorf("название failed не экранировано: %q", msg.text)
|
|
}
|
|
if !strings.Contains(msg.text, "path <bad> & stuff") {
|
|
t.Errorf("текст ошибки не экранирован: %q", msg.text)
|
|
}
|
|
if !strings.Contains(msg.text, "#"+idCode(tid)) {
|
|
t.Errorf("id не в <code>: %q", msg.text)
|
|
}
|
|
if strings.Contains(msg.text, "<x>") {
|
|
t.Errorf("сырая разметка просочилась: %q", msg.text)
|
|
}
|
|
}
|
|
|
|
func TestBot_CallbackRetry(t *testing.T) {
|
|
b, _, _, rev := newTestBot(t, []int64{7})
|
|
rev.data = reviewData(store.StateFailed)
|
|
b.handleCallback(context.Background(), cbFrom(7, "retry:"+tid))
|
|
|
|
if len(rev.retried) != 1 || rev.retried[0] != tid {
|
|
t.Errorf("retried = %v", rev.retried)
|
|
}
|
|
}
|
|
|
|
func TestParseCallback(t *testing.T) {
|
|
a, id, v := parseCallback("type:" + tid + ":series")
|
|
if a != "type" || id != tid || v != "series" {
|
|
t.Errorf("got %q %q %q", a, id, v)
|
|
}
|
|
a, id, v = parseCallback("apply:" + tid)
|
|
if a != "apply" || id != tid || v != "" {
|
|
t.Errorf("got %q %q %q", a, id, v)
|
|
}
|
|
// Устаревшая числовая кнопка (до перехода на ULID) → id пуст.
|
|
if _, id, _ := parseCallback("apply:5"); id != "" {
|
|
t.Errorf("legacy numeric id must be rejected, got %q", id)
|
|
}
|
|
}
|
|
|
|
// candID1/candID2 — валидные lowercase-ULID id кандидатов (callback-data
|
|
// валидируется как ULID).
|
|
const (
|
|
candID1 = "01arz3ndektsv4rrffq69g5fb1"
|
|
candID2 = "01arz3ndektsv4rrffq69g5fb2"
|
|
)
|
|
|
|
// findButton ищет inline-кнопку по callback data во всей клавиатуре.
|
|
func findButton(kb *tgbotapi.InlineKeyboardMarkup, data string) (tgbotapi.InlineKeyboardButton, bool) {
|
|
for _, row := range kb.InlineKeyboard {
|
|
for _, btn := range row {
|
|
if btn.CallbackData != nil && *btn.CallbackData == data {
|
|
return btn, true
|
|
}
|
|
}
|
|
}
|
|
return tgbotapi.InlineKeyboardButton{}, false
|
|
}
|
|
|
|
// withCandidates возвращает ReviewData с двумя кандидатами (второй — выбран).
|
|
func withCandidates() *worker.ReviewData {
|
|
rd := reviewData(store.StateReview)
|
|
rd.Candidates = []store.MetadataCandidate{
|
|
{ID: candID1, Provider: "tvdb", ProviderID: "269613", Title: sql.NullString{String: "Fargo", Valid: true}, Year: sql.NullInt64{Int64: 2014, Valid: true}},
|
|
{ID: candID2, Provider: "tmdb", ProviderID: "60622", Title: sql.NullString{String: "Фарго", Valid: true}, Year: sql.NullInt64{Int64: 2015, Valid: true}, Chosen: true},
|
|
}
|
|
return rd
|
|
}
|
|
|
|
// Карточка ревью с кандидатами показывает кнопку выбора базы; без кандидатов — нет.
|
|
func TestBot_ReviewKeyboardBaseButton(t *testing.T) {
|
|
b, _, _, _ := newTestBot(t, []int64{7})
|
|
|
|
kb := b.reviewKeyboard(withCandidates())
|
|
if _, ok := findButton(kb, "sources:"+tid); !ok {
|
|
t.Error("карточка с кандидатами должна иметь кнопку выбора базы")
|
|
}
|
|
|
|
if kbNone := b.reviewKeyboard(reviewData(store.StateReview)); func() bool {
|
|
_, ok := findButton(kbNone, "sources:"+tid)
|
|
return ok
|
|
}() {
|
|
t.Error("без кандидатов кнопки выбора базы быть не должно")
|
|
}
|
|
}
|
|
|
|
// Список кандидатов: по кнопке на кандидата (pick:<id>:<candID>) + «Назад»;
|
|
// выбранный помечен галочкой, дубли по provider:id схлопнуты.
|
|
func TestBot_CandidatesKeyboard(t *testing.T) {
|
|
b, _, _, _ := newTestBot(t, []int64{7})
|
|
rd := withCandidates()
|
|
// Дубль второго кандидата (тот же provider:id) — должен схлопнуться.
|
|
rd.Candidates = append(rd.Candidates, store.MetadataCandidate{ID: "01arz3ndektsv4rrffq69g5fb3", Provider: "tmdb", ProviderID: "60622"})
|
|
|
|
kb := b.candidatesKeyboard(rd)
|
|
if _, ok := findButton(kb, "pick:"+tid+":"+candID1); !ok {
|
|
t.Error("нет кнопки первого кандидата")
|
|
}
|
|
chosen, ok := findButton(kb, "pick:"+tid+":"+candID2)
|
|
if !ok {
|
|
t.Fatal("нет кнопки выбранного кандидата")
|
|
}
|
|
if !strings.HasPrefix(chosen.Text, "✓ ") {
|
|
t.Errorf("выбранный кандидат должен быть помечен ✓, got %q", chosen.Text)
|
|
}
|
|
if _, ok := findButton(kb, "srcback:"+tid); !ok {
|
|
t.Error("нет кнопки возврата")
|
|
}
|
|
// Дубль не породил третью кнопку выбора.
|
|
if _, ok := findButton(kb, "pick:"+tid+":01arz3ndektsv4rrffq69g5fb3"); ok {
|
|
t.Error("дубль provider:id должен быть схлопнут")
|
|
}
|
|
}
|
|
|
|
// Выбор кандидата: pick с валидным id зовёт ChooseCandidate и обновляет карточку.
|
|
func TestBot_CallbackPick(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
rev.data = withCandidates()
|
|
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":"+candID1))
|
|
|
|
if rev.chosen[tid] != candID1 {
|
|
t.Errorf("ChooseCandidate получил %q, want %q", rev.chosen[tid], candID1)
|
|
}
|
|
if len(api.edits) != 1 { // карточка обновлена на месте (refreshCard)
|
|
t.Errorf("edits = %v, want 1", api.edits)
|
|
}
|
|
}
|
|
|
|
// Невалидный id кандидата из callback отклоняется на границе — домен не зовём.
|
|
func TestBot_CallbackPickInvalidID(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":not-a-ulid"))
|
|
|
|
if len(rev.chosen) != 0 {
|
|
t.Errorf("невалидный id не должен доходить до домена, chosen = %v", rev.chosen)
|
|
}
|
|
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
|
|
t.Errorf("answers = %v, want понятный ответ", api.answers)
|
|
}
|
|
}
|
|
|
|
// «Назад» из списка кандидатов возвращает карточку, домен не трогает.
|
|
func TestBot_CallbackSourcesBack(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
rev.data = withCandidates()
|
|
|
|
b.handleCallback(context.Background(), cbFrom(7, "sources:"+tid))
|
|
if len(rev.chosen) != 0 {
|
|
t.Error("разворачивание списка не должно трогать домен")
|
|
}
|
|
|
|
b.handleCallback(context.Background(), cbFrom(7, "srcback:"+tid))
|
|
if len(rev.chosen) != 0 {
|
|
t.Error("возврат не должен трогать домен")
|
|
}
|
|
if len(api.edits) != 1 { // srcback обновляет карточку на месте
|
|
t.Errorf("edits = %v, want 1 (srcback refreshCard)", api.edits)
|
|
}
|
|
}
|
|
|
|
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
|
|
func TestBot_CallbackStaleButton(t *testing.T) {
|
|
b, api, _, rev := newTestBot(t, []int64{7})
|
|
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
|
|
if len(rev.applied) != 0 {
|
|
t.Error("устаревшая кнопка не должна исполняться")
|
|
}
|
|
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
|
|
t.Errorf("answers = %v, want понятный ответ", api.answers)
|
|
}
|
|
}
|