Files
transcriber/internal/service/undelivered_test.go
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

155 lines
6.6 KiB
Go

package service
import (
"bytes"
"log/slog"
"strings"
"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"
)
// Ответ отправителю уходит после того, как достигнутый рубеж сохранён. Значит,
// недоставка не может быть отказом шага: объявленный отказ засчитался бы воркеру
// сбоем, лёг бы владельцу записью отказа и переписал бы служебные поля
// доведённой записи. Причин недоставки две, исход у них общий.
// downSender изображает неподнятый канал доставки: так ведёт себя заглушка,
// которую ядро получает вместо отправителя Telegram.
type downSender struct {
calls int
}
func (s *downSender) Send(string, int64, *int) error {
s.calls++
return contract.ErrDeliveryChannelDown
}
// journalEnv пересобирает сервис с названным отправителем и своим журналом:
// утверждения судят и состояние записи, и то, что увидел владелец.
func journalEnv(
t *testing.T,
env *pipelineEnv,
sender contract.TelegramMessageSender,
) (*TranscribeService, *bytes.Buffer) {
t.Helper()
journal := &bytes.Buffer{}
svc := NewTranscribeService(
env.repos,
&okMetaViewer{},
&okConverter{},
env.service.recognizer,
sender,
testLimits,
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})),
)
return svc, journal
}
// transcribedRecord доводит запись до рубежа, с которого уходит ответ.
func transcribedRecord(t *testing.T, env *pipelineEnv, svc *TranscribeService) *entity.AudioRecord {
t.Helper()
record := newTelegramRecord(t, env)
for range 5 {
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
if readRecord(t, env, record.Id).State == entity.StateTranscribed {
return readRecord(t, env, record.Id)
}
}
t.Fatal("запись не дошла до рубежа расшифровки")
return nil
}
// Канал не поднят: запись доводится до конца, шаг отказа не объявляет, а
// владелец узнаёт о недоставке из журнала.
func TestUndeliveredOnDownChannelKeepsRecordDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
sender := &downSender{}
svc, journal := journalEnv(t, env, sender)
record := transcribedRecord(t, env, svc)
// Шаг завершается без отказа — именно это воркер считает в свой счётчик.
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
assert.Equal(t, 1, sender.calls, "ответ до отправителя доехал")
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateDone, after.State, "запись осталась на достигнутом рубеже")
assert.False(t, after.IsHalted(), "отказ записи не приписан")
require.NotNil(t, after.TranscriptTextID, "расшифровка сохранена")
text, err := env.repos.Texts.GetByID(*after.TranscriptTextID)
require.NoError(t, err)
require.NotEmpty(t, text.Contents)
written := journal.String()
assert.Contains(t, written, "Reply was not delivered", "недоставка названа")
assert.Contains(t, written, record.Id, "запись несёт идентификатор")
assert.Contains(t, written, "level=WARN", "объявленный режим — «может стать проблемой»")
assert.NotContains(t, written, text.Contents, "текста расшифровки в журнале нет")
}
// Адресат у записи не назван: исход тот же. Прежде эта ветка объявляла отказ
// шага на уже завершённой работе.
func TestUndeliveredWithoutChatKeepsRecordDone(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
sender := &downSender{}
svc, journal := journalEnv(t, env, sender)
record := transcribedRecord(t, env, svc)
// Запись из Telegram, у которой чат не назван: такую отдаёт правка в панели.
// Колонка чистится мимо захвата — иначе подготовка унесла бы запись у шага.
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
stored.Set("tg_chat_id", nil)
require.NoError(t, env.app.Save(stored))
clearDelay(t, env, record.Id)
require.NoError(t, svc.RunStep(t.Context()))
assert.Equal(t, 0, sender.calls, "до отправителя дело не дошло: адресата нет")
after := readRecord(t, env, record.Id)
assert.Equal(t, entity.StateDone, after.State)
assert.False(t, after.IsHalted(), "отказ записи не приписан")
written := journal.String()
assert.Contains(t, written, "Reply was not delivered")
assert.Contains(t, written, record.Id)
assert.Contains(t, written, "chat is not specified", "причина названа")
assert.Contains(t, written, "level=ERROR",
"порча записи громче штатного «бот не настроен»: иначе сигнал утонет")
}
// Запись, принятая по HTTP, до отправителя не доходит вовсе: недоставки нет, и
// записи о ней в журнале быть не должно — иначе журнал владельца заполнят
// строки о записях основного входа.
func TestApiRecordDoesNotReachSenderAndLogsNothing(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
require.NoError(t, err)
sender := &downSender{}
svc, journal := journalEnv(t, env, sender)
require.NoError(t, svc.send(record, "расшифровка записи"))
assert.Equal(t, 0, sender.calls, "отправителя не звали")
assert.NotContains(t, journal.String(), "Reply was not delivered", "недоставки не было")
}