Files
jellybit/internal/tgbot/bot_test.go
T
avandClaude Opus 4.8 612344bab3 конвенции: перенести механизируемое в golangci-lint и internal/archrules
Правило, которое проверяет машина, не должно оставаться прозой: файл конвенций
на сотни строк размазывает внимание по тривиальному — модель добросовестно
проверит именование полей лога и не дойдёт до формы решения.

Включены sloglint (константный msg, стиль ключ-значение), forbidigo (fmt.Print*,
os.Getenv, time.Now мимо store.Now), errorlint (сравнение ошибок), depguard
(сторонние пакеты ошибок). internal/archrules — сканеры на то, что линтером не
выражается: направление зависимостей ядро↔транспорты, AUTOINCREMENT и серверное
время в новых миграциях, матчинг ошибки по тексту.

Код приведён к правилам: logging.StartCall как единая точка отсчёта длительности
внешних вызовов, store.Now вместо time.Now в httpapi и часах воркера,
slog.DiscardHandler в тестах.

Перенесённое вычеркнуто из docs/conventions/* и openspec/config.yaml — прозой
осталось только то, что правилом не выражается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:17:40 +03:00

588 lines
23 KiB
Go

package tgbot
import (
"context"
"database/sql"
"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.DiscardHandler))
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 — уходить моноширинным <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 &amp; Jerry &lt;b&gt;x&lt;/b&gt;") {
t.Errorf("название не экранировано: %q", msg.text)
}
if strings.Contains(msg.text, "<b>") {
t.Errorf("сырая разметка просочилась: %q", msg.text)
}
// Усечённый источник: сущность на границе цела (esc после shorten даёт
// «&amp;…», а не разрубленное «&am…»).
if !strings.Contains(msg.text, "&amp;…") {
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 &amp; B &lt;x&gt;»") {
t.Errorf("название failed не экранировано: %q", msg.text)
}
if !strings.Contains(msg.text, "path &lt;bad&gt; &amp; 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)
}
}
// Карточка ревью: спецсимволы во внешнем пути раскладки и в причинах
// распознавания экранируются (esc после tailPath/join), сырая разметка не
// просачивается. Закрывает сценарий «Внешний путь и причины экранируются».
func TestReviewCard_EscapesPathAndReasons(t *testing.T) {
b, _, _, _ := newTestBot(t, []int64{7})
rd := reviewData(store.StateReview)
rd.Recognition.Reasons = `["Tom & Jerry <b>x</b>"]`
rd.Preview[0].Dst = "/srv/media/series/A & B <x>/file <y>.mkv"
text, _ := b.renderCard(rd)
if !strings.Contains(text, "Tom &amp; Jerry &lt;b&gt;x&lt;/b&gt;") {
t.Errorf("причины не экранированы:\n%s", text)
}
if !strings.Contains(text, "A &amp; B &lt;x&gt;") || !strings.Contains(text, "file &lt;y&gt;.mkv") {
t.Errorf("путь раскладки не экранирован:\n%s", text)
}
if strings.Contains(text, "<b>") || strings.Contains(text, "<x>") {
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:<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)
}
}