- README разгружен: контракт HTTP API, таблицы БД, состояния задач и белый список отданы нормативным источникам ссылками - в `architecture.md` и `review.md` числа и механика захвата задачи заменены ссылками на `database.md`, а описание конвейера ревью — на прогон от 2026-08-11 - в `CLAUDE.md` и `security.md` поправлены границы домена, оценка объёма записи и адрес очереди задач
246 lines
20 KiB
Markdown
246 lines
20 KiB
Markdown
# Ревью: настройка и журнал
|
||
|
||
## Как настроен конвейер
|
||
|
||
Конвейер ревью прогонялся один раз — 2026-08-11, на изменении
|
||
`fix-http-handler-tests`; его триаж лежит в
|
||
`openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md`.
|
||
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые
|
||
ложноположительные» первым прогоном уже пользовались.
|
||
|
||
### Типовые узлы
|
||
|
||
Рода узлов проекта и проверяемые свойства к каждому.
|
||
|
||
**Шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||
`FindAndRunTranscribeCheckJob`):
|
||
|
||
- отличает «задач нет» от отказа и не считает первое ошибкой;
|
||
- при отказе на середине оставляет задачу в состоянии, из которого повтор
|
||
корректен, либо переводит в `failed` осознанно;
|
||
- не теряет ссылку на файл: `job.FileID` переставляется только после того, как
|
||
запись о новом файле создана;
|
||
- повтор шага на той же задаче не создаёт лишних файлов и записей;
|
||
- отвечает пользователю ровно один раз.
|
||
|
||
**Транспорт** (`internal/controller/tg`, `internal/controller/http`):
|
||
|
||
- проверяет право отправителя до всякой работы;
|
||
- не логирует ошибку, которую уже залогировал доменный слой;
|
||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||
- закрывает то, что открыл, на всех ветках выхода.
|
||
|
||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`):
|
||
|
||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
|
||
- различает «сервис ответил отказом» и «сервис недоступен»;
|
||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||
успех молча.
|
||
|
||
**Репозиторий SQLite** (`adapter/repo/sqlite`):
|
||
|
||
- список колонок совпадает во всех четырёх запросах файла;
|
||
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`;
|
||
- захват задачи не выдаёт одну строку двум вызывающим;
|
||
- ошибка драйвера транслируется в доменную у источника.
|
||
|
||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||
`adapter/metaviewer/ffmpeg`):
|
||
|
||
- отсутствие программы в `PATH` отличается от отказа обработки;
|
||
- вход, пришедший от пользователя, не попадает в аргументы командной строки
|
||
неразобранным;
|
||
- пустой или частично записанный выходной файл считается отказом;
|
||
- процесс не висит вечно.
|
||
|
||
**Любой узел** — сверх свойств своего рода:
|
||
|
||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||
становится неотличим от привычного шума (журнал, запись 2026-08-10);
|
||
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
|
||
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним
|
||
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется
|
||
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал,
|
||
запись 2026-08-11).
|
||
|
||
### Типовые ложноположительные
|
||
|
||
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
|
||
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
|
||
логирует его и не считает в метрику. Настоящий дефект рядом другой — проверка
|
||
идёт приведением типа и сломается при первой же обёртке; он уже записан в
|
||
[conventions/errors.md](conventions/errors.md).
|
||
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её
|
||
нет: три воркера читают три разных состояния, и одну строку они не делят.
|
||
Механика захвата и её слабые места — [database.md](database.md),
|
||
«Представление данных». Находка становится настоящей ровно тогда, когда
|
||
появится второй экземпляр процесса или второй воркер на то же состояние.
|
||
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
|
||
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
|
||
Новой находкой это не считается, пока не измерен рост.
|
||
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой
|
||
[security.md](security.md). Находкой считается только новая поверхность,
|
||
выставленная наружу, а не повторение этого факта.
|
||
|
||
### Вопросы по темам
|
||
|
||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
||
|
||
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
|
||
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
|
||
`transcribe.go`, 2026-08-10).
|
||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
|
||
2026-08-10).
|
||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||
2026-08-10).
|
||
- `security`: не попал ли в лог текст расшифровки, имя файла пользователя или
|
||
URL с токеном бота (запрет в [security.md](security.md) и
|
||
[conventions/logging.md](conventions/logging.md)).
|
||
- `security`: не строится ли путь на диске или ключ объекта из значения,
|
||
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
|
||
(чтение `service/transcribe.go`, 2026-08-10).
|
||
- `architecture`: не появился ли второй путь приёма мимо
|
||
`createTranscribeJob` — сегодня через него идут оба входа
|
||
([architecture.md](architecture.md), «Единые точки проекта»).
|
||
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
|
||
заведена одна capability (`openspec/specs/intake`), поведение прочих узлов
|
||
живёт в обзоре под маркерами долга, и соблазн дописать туда ещё максимальный.
|
||
- `conventions`: новая колонка правится во всех четырёх местах репозитория
|
||
(CLAUDE.md, «Инварианты»).
|
||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня
|
||
тестов два файла, и оба мимо конвейера.
|
||
|
||
### Триггеры метки
|
||
|
||
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
|
||
|
||
**Крупное здесь** (поднимает до `large`, ось объёма):
|
||
|
||
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
||
колонку разом;
|
||
- замена хранилища или переход на PocketBase — любой её кусок;
|
||
- смена модели очереди: захват, повторы и воркеры разом;
|
||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||
- изменение, трогающее оба входа сразу — Telegram и HTTP.
|
||
|
||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
||
|
||
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и
|
||
пользователь приложения, до начала работы назвать нельзя;
|
||
- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается
|
||
по ходу, пока конвенция веб-UI пуста;
|
||
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
||
до работы назвать нельзя;
|
||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
||
форма решения зависит от замера;
|
||
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
|
||
- всё, что требует записи в `research/` прежде, чем начать.
|
||
|
||
**Мелкое здесь** (опускает до `small`):
|
||
|
||
- правка текста, который видит пользователь Telegram;
|
||
- новая метрика в `internal/metrics`;
|
||
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
|
||
- правка документов канона.
|
||
|
||
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
||
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
|
||
ни были, они не `small`.
|
||
|
||
### Недоступно проверке
|
||
|
||
**Не проверит ни один проход:**
|
||
|
||
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
|
||
SpeechKit и Object Storage поднять в тесте нечем;
|
||
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||
день, и утверждения о росте остаются условиями, а не замерами;
|
||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||
отдан внешней программе, и она вне нашей границы.
|
||
|
||
**Перестали проверять сознательно:**
|
||
|
||
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма звали его до
|
||
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
|
||
длительность от подставного источника. Своего теста у
|
||
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в
|
||
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md).
|
||
|
||
## Журнал дефектов
|
||
|
||
Верхняя запись найдена конвейером ревью на первом же его прогоне, вторая —
|
||
прогоном гейта при заведении канона 2026-08-10, две нижние восстановлены по
|
||
истории git тогда же. Три нижние помечены `проскочил`: ревью тогда не было, и
|
||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
||
выдумывать его задним числом нельзя.
|
||
|
||
## 2026-08-11 — проверка приёма не могла упасть [пойман ревью]
|
||
|
||
- **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма
|
||
- **Симптом:** тест не поймал ни одного настоящего дефекта приёма, хотя был
|
||
зелёным и выглядел содержательным
|
||
- **Причина:** две штуки одного рода. Тест разбирал ответ в
|
||
`CreateTranscribeJobResponse` — ту самую структуру, чьи теги `json` и
|
||
составляют публичный контракт: переименование тега меняло и проверяемое, и
|
||
ожидаемое разом. И заведение задачи тест подтверждал только эхом ответа, а не
|
||
чтением базы
|
||
- **Чем воспроизведён:** мутацией. Замена тега на `json:"jobId"` и удаление
|
||
`s.jobRepo.Create(job)` из `internal/service/transcribe.go` — тесты в обоих
|
||
случаях оставались зелёными; после правки обе мутации их роняют
|
||
- **Почему не поймали:** проверки писались тем же заходом, что и правились, а
|
||
«зелено» на новом тесте читается как подтверждение. Поймал проход `specs`
|
||
ревью кода, и поймал ровно тем, что добыл оракул мутацией, а не рассуждением
|
||
- **Что меняем:** успех судится по сырому JSON и по строке в базе. В типовые
|
||
узлы, «Любой узел», добавлено свойство «проверка способна упасть» с указанием
|
||
на мутацию как способ его проверить
|
||
|
||
## 2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]
|
||
|
||
- **Где:** `internal/controller/http/transcribe_test.go`
|
||
- **Симптом:** `go test ./...` падает четырьмя случаями; обнаружено первым же
|
||
прогоном гейта при заведении канона
|
||
- **Причина:** тест требует `testdata/sample.m4a`, которого в репозитории нет и
|
||
не могло быть — `.gitignore` содержит `*.m4a`. Остальные случаи записывают в
|
||
файл строку `test audio content` и ждут `201`, а обработчик зовёт настоящий
|
||
`ffprobe`, который такой вход отвергает
|
||
- **Чем воспроизведён:** `go test ./internal/controller/http/` — четыре отказа,
|
||
из них один по отсутствию файла и три по коду `500` вместо `201`
|
||
- **Почему не поймали:** гейта не было вовсе, а `go test` руками, судя по
|
||
результату, не гоняли ни разу с коммита `87d8b05`
|
||
- **Что меняем:** заведена задача `http-handler-tests-never-green`; в гейт
|
||
добавлен шаг `go test ./...`, и красный тест теперь виден. Настоящий остаток
|
||
шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
|
||
типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
|
||
вопросы темы `autotests` — вопрос про изменённый шаг конвейера
|
||
- **Закрыт** 2026-08-11: проверки переписаны, `go test ./...` зелёный и из
|
||
списка объявленных долгов в [CLAUDE.md](../CLAUDE.md) снят
|
||
|
||
## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
|
||
|
||
- **Где:** `internal/service/transcribe.go`, ветка завершения задачи
|
||
- **Симптом:** пользователь Telegram получал пустое сообщение вместо текста
|
||
- **Причина:** SpeechKit возвращал операцию успешной, но с пустым текстом, и
|
||
задача завершалась этим пустым значением
|
||
- **Чем воспроизведён:** восстановлено по коммиту `ec637c0`, оракула нет
|
||
- **Почему не поймали:** конвейера ревью не существовало
|
||
- **Что меняем:** уже сделано — пустой текст подменяется фразой «на записи нет
|
||
текста». Настоящий остаток в другом: свойство «вырожденный ответ внешнего
|
||
сервиса не превращается в успех молча» вынесено в типовой узел «клиент
|
||
внешнего сервиса» выше
|
||
|
||
## 2025-08-17 — длинная расшифровка не доходила до пользователя [проскочил]
|
||
|
||
- **Где:** `internal/adapter/telegram/sender.go`
|
||
- **Симптом:** отправка текста длиннее предела сообщения Telegram завершалась ошибкой
|
||
целиком, пользователь не получал ничего
|
||
- **Причина:** предел длины сообщения на стороне Telegram не учитывался
|
||
- **Чем воспроизведён:** восстановлено по коммиту `822e168`, который тем же
|
||
заходом завёл `internal/adapter/telegram/split_test.go`
|
||
- **Почему не поймали:** конвейера ревью не существовало
|
||
- **Что меняем:** уже сделано — деление по словам с пределом 4000 символов,
|
||
число записано в [database.md](database.md)
|