удалён вход Telegram, владелец записи стал обязателен в схеме

- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
av
2026-08-15 07:24:35 +03:00
parent 97ceb7bb69
commit 8f7c3a057a
74 changed files with 2453 additions and 2733 deletions
+90 -3
View File
@@ -1,10 +1,12 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"sync/atomic"
"testing"
@@ -101,7 +103,7 @@ func TestStructureIsBuiltFromStoredPayload(t *testing.T) {
rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
record := newTelegramRecord(t, env)
record := newRecord(t, env)
drain(t, env, record.Id)
after := readRecord(t, env, record.Id)
@@ -141,7 +143,7 @@ func TestPaidWorkIsNotRepeated(t *testing.T) {
rec := &countingRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
record := newTelegramRecord(t, env)
record := newRecord(t, env)
// Приведение.
require.NoError(t, env.service.RunStep(t.Context()))
@@ -171,7 +173,7 @@ func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
rec.inProgress.Store(3)
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
record := newTelegramRecord(t, env)
record := newRecord(t, env)
require.NoError(t, env.service.RunStep(t.Context())) // приведение
require.NoError(t, env.service.RunStep(t.Context())) // отправка
@@ -195,3 +197,88 @@ func TestPollingPostponesWithoutSpendingAttempts(t *testing.T) {
assert.Len(t, delays, 3, "все три прогона отложили работу")
}
// emptyRecognizer отдаёт готовую операцию с пустым результатом: так выглядит
// оплаченное распознавание, из которого ничего не вышло.
type emptyRecognizer struct {
countingRecognizer
}
func (r *emptyRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
r.fetches.Add(1)
return &entity.RecognitionOutcome{}, nil
}
// Пустой результат виден владельцу сервиса записью журнала «может стать
// проблемой». Прежде этот случай был виден ответом отправителю — «на записи нет
// текста», — и вместе с убранной доставкой он исчез бы вовсе: запись доходит до
// конца молча и от успешной не отличается. Текста в записи нет по инварианту
// приватности: пустой ему взяться неоткуда, а проверка судит уровень и
// идентификатор.
func TestEmptyRecognitionIsNamedInJournal(t *testing.T) {
journal := &bytes.Buffer{}
env := newPipelineEnvWithLogger(t, &okMetaViewer{}, &okConverter{}, &emptyRecognizer{},
slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug})))
record := newRecord(t, env)
drain(t, env, record.Id)
written := journal.String()
assert.Contains(t, written, "Recognition returned empty text", "пустой результат назван")
assert.Contains(t, written, "level=WARN", "уровень — «может стать проблемой»")
assert.Contains(t, written, record.Id, "запись названа идентификатором")
}
// exhaustibleRecognizer отдаёт полный результат один раз, а на всяком следующем
// обращении — пустой: так выглядит провайдер, чей поток закрылся на первом же
// ответе. Отказом это не считается ни у него, ни у нас.
type exhaustibleRecognizer struct {
countingRecognizer
served atomic.Bool
}
func (r *exhaustibleRecognizer) Fetch(context.Context, string) (*entity.RecognitionOutcome, error) {
r.fetches.Add(1)
if r.served.Swap(true) {
return &entity.RecognitionOutcome{}, nil
}
return r.Parse(countingPayload(t0Replicas))
}
// Повторный опрос той же операции — обычное дело: держатель захвата умер,
// сохранение рубежа отказало, человек снял остановку в панели. Пустой ответ
// провайдера при этом не должен стирать уже сохранённую расшифровку: сервис
// объявлен архивом, а восстановления у стёртого текста нет.
func TestEmptySecondAnswerKeepsArchivedText(t *testing.T) {
rec := &exhaustibleRecognizer{}
env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)
record := newRecord(t, env)
drain(t, env, record.Id)
stored := readRecord(t, env, record.Id)
require.NotNil(t, stored.TranscriptTextID, "расшифровка сохранена первым ответом")
before, err := env.repos.Texts.GetByID(*stored.TranscriptTextID)
require.NoError(t, err)
require.NotEmpty(t, before.Contents)
// Второй опрос той же записи: рубеж возвращается на отправленный, и шаг
// забирает результат заново — теперь пустой.
back := readRecord(t, env, record.Id)
back.MoveToState(entity.StateSubmitted)
require.NoError(t, env.recordRepo.Save(back, ""))
clearDelay(t, env, record.Id)
require.NoError(t, env.service.RunStep(t.Context()))
// Проверка обязана дойти до второго ответа: иначе она зеленела бы, ничего не
// проверив, — а класс «проверка, не способная упасть» в этом проекте ловили
// уже трижды.
require.EqualValues(t, 2, rec.fetches.Load(), "результат забирали дважды")
after, err := env.repos.Texts.GetByID(*stored.TranscriptTextID)
require.NoError(t, err)
assert.Equal(t, before.Contents, after.Contents,
"пустой ответ провайдера не стирает сохранённую расшифровку")
}