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 } type sentMsg struct { chatID int64 text string hasKB bool } 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}) case tgbotapi.EditMessageTextConfig: f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil}) } 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() {} 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 []int64 refined map[int64]string typed map[int64]string deferred []int64 canceled []int64 } func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) { return f.data, nil } func (f *fakeReviewer) Apply(_ context.Context, id int64) error { f.applied = append(f.applied, id) return nil } func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error { if f.refined == nil { f.refined = map[int64]string{} } f.refined[id] = hint return nil } func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error { if f.typed == nil { f.typed = map[int64]string{} } f.typed[id] = t return nil } func (f *fakeReviewer) Defer(_ context.Context, id int64) error { f.deferred = append(f.deferred, id) return nil } func (f *fakeReviewer) Cancel(_ context.Context, id int64) error { f.canceled = append(f.canceled, id) return nil } func reviewData(state store.State) *worker.ReviewData { s, e := 2, 1 return &worker.ReviewData{ Download: store.Download{ID: 5, State: state, 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: 5, 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, "Принято #5") { t.Errorf("sent = %+v", api.sent) } } 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, 5) b.handleMessage(context.Background(), msgFrom(7, "это второй сезон")) if rev.refined[5] != "это второй сезон" { 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:5")) if len(rev.applied) != 1 || rev.applied[0] != 5 { 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) } } func TestBot_CallbackType(t *testing.T) { b, _, _, rev := newTestBot(t, []int64{7}) b.handleCallback(context.Background(), cbFrom(7, "type:5:movie")) if rev.typed[5] != "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:5")) if id, ok := b.takePending(7); !ok || id != 5 { t.Errorf("pending = %d,%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:5")) if len(rev.applied) != 0 { t.Error("чужой колбэк не должен исполняться") } } func TestBot_NotifyReview(t *testing.T) { b, api, _, _ := newTestBot(t, []int64{7, 8}) b.Notify(context.Background(), 5, 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, "Нужно подтверждение #5") { 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(), 5, worker.EventDone) if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") { t.Errorf("sent = %+v", api.sent) } } func TestParseCallback(t *testing.T) { a, id, v := parseCallback("type:5:series") if a != "type" || id != 5 || v != "series" { t.Errorf("got %q %d %q", a, id, v) } a, id, v = parseCallback("apply:9") if a != "apply" || id != 9 || v != "" { t.Errorf("got %q %d %q", a, id, v) } }