Files
transcriber/internal/service/shutdown_test.go
T
av f494dcb83e Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: 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, уводя диагностику
2026-08-13 10:27:54 +03:00

84 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// killedConverter ведёт себя как настоящий `ffmpeg`, убитый по контексту:
// дожидается отмены и отдаёт отказ, который **не** несёт `context.Canceled`.
// Это не упрощение, а суть проверки: `exec` отдаёт `*exec.ExitError` с текстом
// «signal: killed», и отличить остановку от негодной записи по самой ошибке
// нельзя — только по контексту шага.
type killedConverter struct {
cancel func()
}
func (c *killedConverter) Convert(ctx context.Context, _, _ string) error {
c.cancel()
<-ctx.Done()
return errors.New("ffmpeg conversion failed: signal: killed")
}
// Остановка сервиса посреди конвертации не выносит записи приговора: задача
// остаётся пригодной к повтору, попытку не тратит и отправителю о сбое,
// которого не было, не сообщает. Прежде любой отказ `Convert` уводил задачу в
// терминальное `failed`, откуда её возвращает только владелец правкой в панели.
func TestShutdownDuringConversionKeepsJobRetryable(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
converter := &killedConverter{cancel: cancel}
env := newPipelineEnv(t, &okMetaViewer{}, converter)
job := newTelegramJob(t, env)
err := env.service.FindAndRunConversionJob(ctx)
require.Error(t, err, "шаг обязан сообщить об обрыве наверх")
require.ErrorIs(t, err, context.Canceled, "обрыв узнаётся по смыслу, а не по тексту")
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State, "задача осталась на повтор, а не похоронена")
assert.Nil(t, after.AcquisitionID, "захват снят: задачу возьмёт следующий прогон")
assert.Equal(t, 0, after.Attempts, "остановка попытки не тратит")
assert.Nil(t, after.ErrorText, "приговора не выносили")
assert.Empty(t, env.sender.messages, "отправителю о несуществующем сбое не сообщают")
}
// Задача, которую шаг не успел взять, потому что нас уже остановили, остаётся
// нетронутой: захват не случился, попытка не потрачена.
func TestShutdownBeforeStepLeavesJobUntouched(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
job := newTelegramJob(t, env)
ctx, cancel := context.WithCancel(t.Context())
cancel()
err := env.service.FindAndRunConversionJob(ctx)
// Исход «шаг не сделал ничего» — это `NoopJobError`: воркер не пишет о нём
// владельцу и не считает его в метрику.
require.Error(t, err)
var noop *contract.NoopJobError
require.ErrorAs(t, err, &noop)
after, err := env.jobRepo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
assert.Equal(t, 0, after.Attempts, "захвата не было — попытке взяться неоткуда")
record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
assert.Empty(t, record.GetString("acquisition_id"))
}