Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage принимают ctx вместо context.Background, скачивание записи идёт запросом с контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе - прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и отправителю о несуществующем сбое не сообщает; воркер не считает остановку отказом, а задача не забирается вовсе, если нас уже остановили - клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком. Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а транспорт бота токена не получает вовсе - принятие операции распознавания защищено от отмены своим пределом: SpeechKit мог её принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз - приём по HTTP доводит запись до задачи независимо от отправителя: на контексте запроса один обрыв соединения терял полностью загруженную запись - ответ Telegram с не-2xx кодом больше не становится записью: прежде тело отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package tg
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// probeClient подменяет клиента бота и запоминает, кого спрашивали. Через него
|
||||
// проверяется стык: скачивание обязано идти клиентом бота, а не общим
|
||||
// `http.DefaultClient` — чистку отказа от адреса с токеном несёт именно клиент
|
||||
// (`internal/adapter/telegram`). Подмена на общий клиент правил гейта не
|
||||
// нарушает, поэтому сторожить стык может только проверка.
|
||||
type probeClient struct {
|
||||
seen []string
|
||||
download func(w *probeResponse)
|
||||
}
|
||||
|
||||
type probeResponse struct {
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (c *probeClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.seen = append(c.seen, req.URL.Path)
|
||||
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/getMe"):
|
||||
return jsonResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"probe_bot"}}`), nil
|
||||
case strings.Contains(req.URL.Path, "/getFile"):
|
||||
return jsonResponse(`{"ok":true,"result":{"file_id":"x","file_path":"voice/file_1.ogg"}}`), nil
|
||||
}
|
||||
|
||||
answer := &probeResponse{status: http.StatusOK, body: "аудио"}
|
||||
if c.download != nil {
|
||||
c.download(answer)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: answer.status,
|
||||
Body: io.NopCloser(strings.NewReader(answer.body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonResponse(body string) *http.Response {
|
||||
header := make(http.Header)
|
||||
header.Set("Content-Type", "application/json")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: header,
|
||||
}
|
||||
}
|
||||
|
||||
func newProbeController(t *testing.T, client *probeClient) *TelegramController {
|
||||
t.Helper()
|
||||
|
||||
// Клиент подставной, поэтому адрес значения не имеет — важно лишь, что
|
||||
// библиотека соберёт из него разбираемый URL.
|
||||
bot, err := tgbotapi.NewBotAPIWithClient(
|
||||
"7654321:AAHsecretBOTtokenVALUE",
|
||||
"http://telegram.probe/bot%s/%s",
|
||||
client,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &TelegramController{
|
||||
bot: bot,
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
}
|
||||
|
||||
// Скачивание идёт клиентом бота: иначе отказ пойдёт мимо чистки и унесёт токен.
|
||||
func TestDownloadGoesThroughBotClient(t *testing.T) {
|
||||
client := &probeClient{}
|
||||
controller := newProbeController(t, client)
|
||||
|
||||
body, name, err := controller.downloadAudioFile(t.Context(), "file-id")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := body.Close(); err != nil {
|
||||
t.Errorf("не удалось закрыть тело: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
assert.Equal(t, "voice/file_1.ogg", name)
|
||||
require.Len(t, client.seen, 3, "клиент бота видел все обращения: getMe, getFile и скачивание")
|
||||
assert.Contains(t, client.seen[2], "voice/file_1.ogg", "скачивание ушло мимо клиента бота")
|
||||
}
|
||||
|
||||
// Отказ выдачи файла — это не запись: тело такого ответа не должно доехать до
|
||||
// хранилища и умереть на `ffprobe`, уведя диагностику к чужой причине.
|
||||
func TestDownloadRejectsNonOKStatus(t *testing.T) {
|
||||
client := &probeClient{download: func(w *probeResponse) {
|
||||
w.status = http.StatusUnauthorized
|
||||
w.body = `{"ok":false,"error_code":401,"description":"Unauthorized"}`
|
||||
}}
|
||||
controller := newProbeController(t, client)
|
||||
|
||||
body, _, err := controller.downloadAudioFile(t.Context(), "file-id")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, body, "тело отказа наружу не отдают")
|
||||
assert.Contains(t, err.Error(), "401", "код ответа назван — по нему видно, что отказал Telegram")
|
||||
}
|
||||
Reference in New Issue
Block a user