package tgbot import ( "context" "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 } 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) 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: `["неполный пак"]`, }, 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 — уходить моноширинным . // Иначе спецсимволы (`<`/`>`/`&`) в названии/пути сломали бы разметку. 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) } } 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) } } // Нажатие устаревшей кнопки со старым числовым 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) } }