- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет, файл без него негоден; bot_token стал только ключом доступа и при enabled = false не читается вовсе, а пустой при enabled = true роняет старт - выключенный вход даёт подъём одним входом без единого обращения к Telegram и записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение - отказ разбора файла настроек больше не пересказывает toml — её ParseError несёт в тексте разбираемое значение, и оборванная строка секретного ключа уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
209 lines
11 KiB
Go
209 lines
11 KiB
Go
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, "это ошибка настройки, а не режим")
|
|
}
|