package main import ( "bytes" "errors" "log/slog" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/prometheus/client_golang/prometheus/testutil" "git.vakhrushev.me/av/transcriber/internal/adapter/telegram" "git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/metrics" ) // Разрез сборки — то, ради чего написано изменение, — до этих проверок не // держался ничем: инвертируй его, и весь набор оставался зелёным. // // Судится решение, а не обращение к Telegram: обращение ходит в сеть, а // боевым токеном запускаться запрещено. func journalLogger() (*slog.Logger, *bytes.Buffer) { journal := &bytes.Buffer{} return slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})), journal } // Пустой ключ доступа при включённом входе — ошибка настройки, а не режим. // Прежде он давал мягкий подъём без бота, и это был тот самый второй смысл, // который изменение разводит с первым: «вход выключен» объявляет признак. // // Ветка по построению недостижима — пустой ключ ловит проверка настроек, — но // исход у неё тот же, и потому она проверяется: убрав её, мы отправили бы // пустой ключ в общий случай, то есть в «недоступность», и обход проверки дал // бы тихий подъём без бота. func TestTelegramFromBotOnEmptyTokenReturnsError(t *testing.T) { logger, journal := journalLogger() bot, sender, err := telegramFromBot(nil, telegram.ErrEmptyToken, logger) require.ErrorIs(t, err, telegram.ErrEmptyToken, "отказ поднят вызывающему нетронутым") assert.Nil(t, bot) assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим") assert.NotContains(t, journal.String(), "Telegram bot is not started", "о законном отсутствии входа речи нет: вход заявлен и не обеспечен ключом") } // Выключенный вход — решение владельца: сервис встаёт одним входом, ядро // получает заглушку, а владелец узнаёт об этом одной записью «к сведению». func TestTelegramFromConfigDisabledKeepsStarting(t *testing.T) { logger, journal := journalLogger() cfg := config.TelegramConfig{Enabled: false, BotToken: ""} bot, sender, err := telegramFromConfig(cfg, failingBuild(t), logger) require.NoError(t, err, "выключенный вход старт не роняет") assert.Nil(t, bot, "клиента нет — транспорт не поднимется") require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль") require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown, "заглушка говорит, что канал не поднят") written := journal.String() assert.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано") assert.Contains(t, written, "disabled in configuration", "причина названа") assert.Contains(t, written, "level=INFO", "уровень «к сведению»: это выбор владельца, а не отклонение") assert.Equal(t, 1, strings.Count(written, "Telegram bot is not started"), "запись ровно одна: вторая была бы записью о том же факте") // Ряд метрики заводится первым обращением к нему. Пропади эта строка из // ветки — на `/metrics` не появится ряда вовсе, и правило наблюдения вида // «равен нулю» не сработает на отсутствующем ряде: потерянный вход снова // станет невидимым. assert.Zero(t, testutil.ToFloat64(metrics.IntakeUpGauge.WithLabelValues("telegram")), "признак поднятости входа выставлен в ноль") } // Главное утверждение выключенного входа судится **счётчиком обращений**, а не // исходом. «Бот не заведён, отказа нет» остаётся верным и тогда, когда ветка // встала после обращения, а обращение ушло в живой Telegram боевым токеном. func TestTelegramFromConfigDisabledNeverBuildsClient(t *testing.T) { logger, _ := journalLogger() // Ключ заполнен — то самое состояние, ради которого два значения и // разводятся. Значение заведомо ненастоящее. cfg := config.TelegramConfig{Enabled: false, BotToken: "123456:AA-fake"} calls := 0 build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { calls++ return nil, nil } _, _, err := telegramFromConfig(cfg, build, logger) require.NoError(t, err) assert.Zero(t, calls, "к Telegram не уходит ни одного обращения") } // failingBuild роняет проверку, если сборку клиента всё-таки позвали. func failingBuild(t *testing.T) func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { t.Helper() return func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { t.Fatal("сборка клиента позвана при выключенном входе") return nil, nil } } // Telegram ответил, что такого бота нет, — ошибка настройки, а не режим: бот по // этому токену не появится ни от ожидания, ни от повтора, и старт роняется. func TestTelegramFromBotOnRejectedTokenReturnsError(t *testing.T) { logger, journal := journalLogger() rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"} bot, sender, err := telegramFromBot(nil, rejected, logger) require.ErrorIs(t, err, rejected, "отказ поднят вызывающему нетронутым") assert.Nil(t, bot) assert.Nil(t, sender, "заглушка тут не подставляется: это ошибка, а не режим") assert.NotContains(t, journal.String(), "Telegram bot is not started", "о законном отсутствии входа речи нет: вход заявлен и отвергнут") } // До Telegram не дошли — недоступность: на подъём сервиса она не влияет. // Решение владельца 2026-08-13; иначе чужая авария кладёт и основной вход, и // панель, и конвейер, которому Telegram не нужен вовсе. func TestTelegramFromBotOnUnreachableTelegramKeepsStarting(t *testing.T) { logger, journal := journalLogger() unreachable := errors.New("dial tcp: connection refused") bot, sender, err := telegramFromBot(nil, unreachable, logger) require.NoError(t, err, "недоступность Telegram старт не роняет") assert.Nil(t, bot, "клиента нет — транспорт не поднимется") require.NotNil(t, sender, "ядро получает отправителя всегда, а не ноль") require.ErrorIs(t, sender.Send("любой ответ", 1, nil), contract.ErrDeliveryChannelDown) written := journal.String() assert.Contains(t, written, "Telegram bot is not started", "о неподнятом входе сказано") assert.Contains(t, written, "unreachable", "причина названа") assert.Contains(t, written, "level=WARN") } // Годный токен даёт настоящего отправителя и клиента для транспорта: прежний // путь сохранён, и о неподнятом боте не говорится ничего. func TestTelegramFromBotOnLiveBotGivesRealSender(t *testing.T) { logger, journal := journalLogger() live := &tgbotapi.BotAPI{} bot, sender, err := telegramFromBot(live, nil, logger) require.NoError(t, err) assert.Same(t, live, bot, "транспорт получит того же клиента, что и отправитель") require.NotNil(t, sender) _, stub := sender.(*telegram.AbsentMessageSender) assert.False(t, stub, "это настоящий отправитель, а не заглушка") assert.NotContains(t, journal.String(), "Telegram bot is not started") } // Включённый вход — основной путь нового кода, и связка «собрать клиента → // разобрать исход» покрывается только целиком: обе её половины по отдельности // проверены, а переданное не то поле или потерянный результат видны лишь здесь. func TestTelegramFromConfigEnabledPassesTokenAndOutcome(t *testing.T) { logger, _ := journalLogger() cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"} live := &tgbotapi.BotAPI{} var seen string build := func(token string, _ *slog.Logger) (*tgbotapi.BotAPI, error) { seen = token return live, nil } bot, sender, err := telegramFromConfig(cfg, build, logger) require.NoError(t, err) assert.Equal(t, cfg.BotToken, seen, "в сборку уходит ключ доступа из настроек") assert.Same(t, live, bot, "собранный клиент доезжает до транспорта") require.NotNil(t, sender) _, stub := sender.(*telegram.AbsentMessageSender) assert.False(t, stub, "это настоящий отправитель, а не заглушка") } // Обратная сторона той же связки: отказ сборки доезжает до разбора исхода, а не // теряется по дороге. func TestTelegramFromConfigEnabledCarriesBuildFailure(t *testing.T) { logger, _ := journalLogger() cfg := config.TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"} rejected := &tgbotapi.Error{Code: 401, Message: "Unauthorized"} build := func(string, *slog.Logger) (*tgbotapi.BotAPI, error) { return nil, rejected } bot, sender, err := telegramFromConfig(cfg, build, logger) require.ErrorIs(t, err, rejected, "отказ сборки доехал до вызывающего") assert.Nil(t, bot) assert.Nil(t, sender, "это ошибка настройки, а не режим") }