http: тесты приёма переписаны на подставные адаптеры

- проверки больше не зовут ffprobe и не меняют рабочий каталог процесса;
  добавлены случаи на отказ чтения метаданных и на отсутствие поля audio
- заведена спека intake на приём по HTTP, ADR о подставных адаптерах,
  запись в журнал ревью о проверке, которая не могла упасть
- go test снят из объявленных долгов CLAUDE.md, послабление errcheck
  для _test.go в .golangci.yml убрано
This commit is contained in:
av
2026-08-11 08:35:11 +03:00
parent bb973b0a68
commit 6c04c801c9
14 changed files with 1175 additions and 205 deletions
@@ -0,0 +1,123 @@
## Context
Тесты приёма записи по HTTP лежат в репозитории с коммита `87d8b05` и не проходили
ни разу. Отказов четыре, и причина у них одна: тест поднимает приём вместе с
**настоящим** источником метаданных — внешней программой `ffprobe`, — а
скармливает ему либо файл, которого нет, либо строку «test audio content».
`ffprobe` такой вход отвергает, приём отвечает отказом, тест ждал успеха.
Отсюда следствие дороже самих тестов: красный `go test` в проекте объявлен долгом,
и настоящий отказ приёма от него неотличим.
Второе, что мешает: каталог хранения приём берёт из строки `data/files`,
относительной к рабочему каталогу процесса. Чтобы файлы не улетали в репозиторий,
каждый тест зовёт `os.Chdir` — то есть меняет состояние **всего процесса**.
Параллельно такие тесты гонять нельзя, а `errcheck` на непроверенных `os.Chdir`
молчит только потому, что весь `_test.go` вынесен в исключения линтера.
## Goals / Non-Goals
**Goals:**
- проверка приёма судит **наш** код, а не способность `ffprobe` разобрать вход;
- отказ чтения метаданных проверен отдельным случаем: сегодня эту ветку не
проверяет ничто;
- проверки проходят на чистом клоне без подготовки файлов руками и без
установленного `ffprobe`;
- проверки не трогают состояние процесса и не мешают друг другу.
**Non-Goals:**
- поведение приёма не меняется — ни коды ответов, ни имена полей, ни раскладка
файлов на диске. Задача про то, чем поведение проверяется;
- код `internal/service` и `internal/controller/http` не правится: подстановка
туда уже заведена, и пользоваться ей — вопрос теста, а не сервиса;
- проверка настоящего `ffprobe` на настоящей записи здесь не заводится — см.
«Risks / Trade-offs».
## Decisions
### Подставной источник метаданных вместо настоящего `ffprobe`
Приём берёт длительность через интерфейс `contract.AudioMetaViewer`, а
реализацию получает снаружи при сборке. Тест подставляет свою: она отдаёт
заданную длительность и, когда тесту нужен отказ, — заданную ошибку. Одним
типом закрываются оба случая, и ветка отказа впервые становится проверяемой.
Рассмотрено и отвергнуто:
- **положить настоящую запись в `testdata`.** Отвергнуто дважды: `.gitignore`
строкой `*.m4a` её не пустит, а `CLAUDE.md` прямо говорит, что `testdata` в
проекте нет и тесты создают нужное во временном каталоге. Снимать запрет ради
теста — менять правило проекта под удобство одного файла;
- **порождать запись `ffmpeg` прямо в тесте.** Отвергнуто: проверка приёма
начинает требовать установленных `ffmpeg` и `ffprobe`, а критерий приёмки
требует обратного — прогона с `ffprobe`, убранным из `PATH`;
- **проверять приём сквозь настоящий `ffprobe`.** Отвергнуто: это проверка
внешней программы, а не нашего кода. Она найдёт отказ `ffprobe` и не найдёт
ошибку в приёме — ровно наоборот тому, зачем эти тесты писались.
### Каталог хранения задаётся снаружи, а не рабочим каталогом процесса
Каталог тесту даёт `t.TempDir()`, и он же уезжает в сборку сервиса. `os.Chdir`
уходит целиком. Человек увидит разницу в двух местах: тесты можно гонять
параллельно, и упавший тест больше не оставляет процесс в чужом каталоге, ломая
следующие за ним.
Рассмотрено и отвергнуто: **оставить `os.Chdir`, но восстанавливать каталог
надёжнее.** Отвергнуто — надёжного способа нет: рабочий каталог у процесса один
на все горутины, и любой параллельный тест увидит чужой.
### Запрос собирается из байтов, а не из файла на диске
Сегодня вспомогательная функция открывает файл по пути, поэтому каждому случаю
нужен файл в текущем каталоге. Она начинает принимать имя и содержимое —
диск из подготовки запроса уходит, а имя файла (то, чем проверяется выбор
расширения) задаётся прямо, без создания одноимённого файла.
### Внешних программ в этом тесте не остаётся ни одной
Конвертер в сборке теста тоже настоящий, хотя проверки его не зовут: приём
конвертацию не делает. Он заменяется подставным вместе с источником метаданных.
Смысл не в экономии: после этого «тест не зависит от внешних программ»
проверяется взглядом на список импортов, а не рассуждением о том, какие ветки
кода отработают.
### Послабление линтера для тестов снимается
Исключение `errcheck` на `_test.go` в `.golangci.yml` заведено под непроверенные
`os.Chdir`. Их не остаётся — исключение снимается вместе с ними. Держать
послабление после того, как ушла его причина, значит оставить весь будущий тестовый
код без проверки возвращаемых ошибок и не помнить почему.
## Risks / Trade-offs
- **Настоящий `ffprobe` теперь не проверяется ничем.** До этой правки его звал
тест приёма — правда, звал так, что тот всегда отказывал, то есть проверял
отказ и выдавал его за успех. Формально покрытие не теряется: проверялось и
раньше ничего. Но дыра называется прямо — у `internal/adapter/metaviewer/ffmpeg`
своего теста нет, и разбор вывода `ffprobe` не проверен. → Смягчение: находка
уходит в урожай отдельной задачей; здесь она не чинится, потому что требует
записи в репозитории либо отдельного вида проверок, а это своё решение.
- **Снятое послабление линтера действует на весь будущий тестовый код.** →
Смягчение: это и есть цель. Если оно окажется тяжёлым, его вернут осознанно и
с причиной в самом файле, а не по наследству.
- **Норма про имя отправителя накрывает хранилище, но не журнал.** Ревью дизайна
нашло, что приём пишет имя файла пользователя в журнал — это нарушение
инварианта «содержимое записи остаётся приватным», объявленного критическим и
необратимым, и идёт оно по общему пути, то есть и для записей из Telegram.
Решением человека на чекпоинте правка вынесена отдельной задачей: она про
поведение сервиса, а не про проверки. → Смягчение: находка уходит в урожай.
Спека при этом нормирует только хранилище и о журнале молчит намеренно —
писать норму, которой код заведомо не следует, значит завести спеку, которая
врёт с первого дня.
- **Отказ чтения метаданных оставляет файл на диске.** Соседняя ветка отказа
(не удалось завести учётную запись файла) файл убирает, эта — нет, как и
отказ записи на диск. Ни задачи, ни записи в учёте под такой файл нет, и
сопоставить его не с чем. Решением человека на чекпоинте вынесено отдельной
задачей: правка трогает три ветки отказа и меняет поведение на диске. →
Смягчение: находка уходит в урожай; сценарий отказа в спеке нормирует только
то, что проверяется здесь, — код ответа и незаведённую задачу.
- **Спека `intake` описывает только приём по HTTP.** Приём из Telegram остаётся
в коде и без требований. → Смягчение: назван в самой спеке. Требование,
написанное без проверки, было бы предположением, а не нормой.