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 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) 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_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 — уходить моноширинным . // Иначе спецсимволы (`<`/`>`/`&`) в названии/пути сломали бы разметку. 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 x" // Источник длиннее лимита 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 не в : %q", msg.text) } // Спецсимволы названия экранированы, сырая разметка не просочилась. if !strings.Contains(msg.text, "Tom & Jerry <b>x</b>") { t.Errorf("название не экранировано: %q", msg.text) } if strings.Contains(msg.text, "") { 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 " rd.Download.ErrorMsg = store.NullString("path & 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 не в : %q", msg.text) } if strings.Contains(msg.text, "") { t.Errorf("сырая разметка просочилась: %q", msg.text) } } // Карточка ревью: спецсимволы во внешнем пути раскладки и в причинах // распознавания экранируются (esc после tailPath/join), сырая разметка не // просачивается. Закрывает сценарий «Внешний путь и причины экранируются». func TestReviewCard_EscapesPathAndReasons(t *testing.T) { b, _, _, _ := newTestBot(t, []int64{7}) rd := reviewData(store.StateReview) rd.Recognition.Reasons = `["Tom & Jerry x"]` rd.Preview[0].Dst = "/srv/media/series/A & B /file .mkv" text, _ := b.renderCard(rd) if !strings.Contains(text, "Tom & Jerry <b>x</b>") { t.Errorf("причины не экранированы:\n%s", text) } if !strings.Contains(text, "A & B <x>") || !strings.Contains(text, "file <y>.mkv") { t.Errorf("путь раскладки не экранирован:\n%s", text) } if strings.Contains(text, "") || strings.Contains(text, "") { t.Errorf("сырая разметка просочилась:\n%s", text) } } // opErr: при наличии download id он уходит моноширинным (tap-to-copy) под // ключом download_id=; без id — дружелюбный текст без ключа. Закрывает сценарий // «Id в сообщении об ошибке копируется». func TestOpErr_IDMonospace(t *testing.T) { if got := opErr("Не удалось выполнить действие", tid); !strings.Contains(got, "download_id="+idCode(tid)) { t.Errorf("id не моноширинным под ключом download_id: %q", got) } if got := opErr("Готово", ""); strings.Contains(got, "download_id") { t.Errorf("без id ключа download_id быть не должно: %q", got) } } 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("pick:" + tid + ":" + tid) if a != "pick" || id != tid || v != tid { 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::) + «Назад»; // выбранный помечен галочкой, дубли по 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) } }