http: тесты приёма переписаны на подставные адаптеры
- проверки больше не зовут ffprobe и не меняют рабочий каталог процесса; добавлены случаи на отказ чтения метаданных и на отсутствие поля audio - заведена спека intake на приём по HTTP, ADR о подставных адаптерах, запись в журнал ревью о проверке, которая не могла упасть - go test снят из объявленных долгов CLAUDE.md, послабление errcheck для _test.go в .golangci.yml убрано
This commit is contained in:
@@ -15,11 +15,6 @@ linters:
|
|||||||
- os.Remove
|
- os.Remove
|
||||||
# Метод сам логирует ошибку отправки, вызывающему она не нужна
|
# Метод сам логирует ошибку отправки, вызывающему она не нужна
|
||||||
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
|
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send
|
||||||
exclusions:
|
|
||||||
rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- errcheck
|
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
|
|||||||
@@ -93,12 +93,8 @@ task gate # весь набор проверок разом
|
|||||||
- покрытие изменённых строк не считается ничем.
|
- покрытие изменённых строк не считается ничем.
|
||||||
|
|
||||||
**Гейт на `master` сегодня красный, и это объявленный долг, а не поломка дня.**
|
**Гейт на `master` сегодня красный, и это объявленный долг, а не поломка дня.**
|
||||||
Два известных отказа:
|
Известный отказ один:
|
||||||
|
|
||||||
- `go test ./...` падает в `internal/controller/http`: тесты требуют
|
|
||||||
`testdata/sample.m4a`, которого в репозитории нет и не было (`*.m4a` стоит в
|
|
||||||
`.gitignore`), а остальные скармливают строку `test audio content` реальному
|
|
||||||
`ffprobe` и ждут 201. Заведено задачей `http-handler-tests-never-green`;
|
|
||||||
- `golangci-lint run` даёт 4 замечания в существующем коде: два непроверенных
|
- `golangci-lint run` даёт 4 замечания в существующем коде: два непроверенных
|
||||||
`Close` (`adapter/recognizer/yandex/speechkit.go:55`, `main.go:124`) и два
|
`Close` (`adapter/recognizer/yandex/speechkit.go:55`, `main.go:124`) и два
|
||||||
сравнения ошибок приведением типа (`controller/worker/worker.go:51`,
|
сравнения ошибок приведением типа (`controller/worker/worker.go:51`,
|
||||||
@@ -106,9 +102,13 @@ task gate # весь набор проверок разом
|
|||||||
[docs/conventions/errors.md](docs/conventions/errors.md), заведён задачей
|
[docs/conventions/errors.md](docs/conventions/errors.md), заведён задачей
|
||||||
`errors-as-instead-of-typecast`.
|
`errors-as-instead-of-typecast`.
|
||||||
|
|
||||||
Новые отказы отличай от этих. Пока они живы, «зелёный гейт» в определении
|
Новые отказы отличай от этого. Пока он жив, «зелёный гейт» в определении
|
||||||
сделанного означает «не добавилось ничего сверх перечисленного».
|
сделанного означает «не добавилось ничего сверх перечисленного».
|
||||||
|
|
||||||
|
**`go test ./...` больше долгом не считается.** Тесты приёма по HTTP чинены
|
||||||
|
задачей `http-handler-tests-never-green`; красный `go test` теперь означает
|
||||||
|
поломку, и списывать его на наследство нельзя.
|
||||||
|
|
||||||
## Запреты
|
## Запреты
|
||||||
|
|
||||||
- **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии.
|
- **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Проверки не зовут внешних программ
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-11
|
||||||
|
- **Источник:** openspec/changes/archive/2026-08-11-fix-http-handler-tests/design.md
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Тесты приёма получают длительность записи от подставного источника метаданных, а
|
||||||
|
не от `ffprobe`. Годность содержимого судит адаптер, тест судит наш код.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Цитата из источника, раздел `Decisions`:
|
||||||
|
|
||||||
|
> **проверять приём сквозь настоящий `ffprobe`.** Отвергнуто: это проверка
|
||||||
|
> внешней программы, а не нашего кода. Она найдёт отказ `ffprobe` и не найдёт
|
||||||
|
> ошибку в приёме — ровно наоборот тому, зачем эти тесты писались.
|
||||||
|
|
||||||
|
Отвергнуты там же два очевидных пути, и оба по записанным правилам проекта, а не
|
||||||
|
по вкусу:
|
||||||
|
|
||||||
|
> **положить настоящую запись в `testdata`.** Отвергнуто дважды: `.gitignore`
|
||||||
|
> строкой `*.m4a` её не пустит, а `CLAUDE.md` прямо говорит, что `testdata` в
|
||||||
|
> проекте нет и тесты создают нужное во временном каталоге. Снимать запрет ради
|
||||||
|
> теста — менять правило проекта под удобство одного файла;
|
||||||
|
>
|
||||||
|
> **порождать запись `ffmpeg` прямо в тесте.** Отвергнуто: проверка приёма
|
||||||
|
> начинает требовать установленных `ffmpeg` и `ffprobe`, а критерий приёмки
|
||||||
|
> требует обратного — прогона с `ffprobe`, убранным из `PATH`.
|
||||||
|
|
||||||
|
Решение попадает в журнал как **намеренный отказ от очевидного подхода**: файл с
|
||||||
|
настоящей записью в `testdata` — первое, что сделал бы человек, и отказ от него
|
||||||
|
из кода не виден.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` прогон проверок на чистом клоне зелёный без подготовки файлов руками и без
|
||||||
|
установленных внешних программ. Проверено сборкой тестового бинарника и
|
||||||
|
прогоном под `env -i PATH=<пустой каталог>`.
|
||||||
|
- `+` ветка отказа чтения метаданных впервые проверяема: подставной источник
|
||||||
|
умеет вернуть ошибку, настоящий `ffprobe` по заказу не отказывает.
|
||||||
|
- `+` проверки не держат состояния процесса: каталог хранения задаётся снаружи,
|
||||||
|
`os.Chdir` ушёл, и параллельный прогон перестал быть запрещённым.
|
||||||
|
- `−` разбор вывода настоящего `ffprobe` не проверяется ничем: своего теста у
|
||||||
|
`internal/adapter/metaviewer/ffmpeg` нет. Формально покрытие не потеряно —
|
||||||
|
прежние проверки звали его так, что он всегда отказывал, — но дыра теперь
|
||||||
|
наша и записана в [../review.md](../review.md), «Перестали проверять
|
||||||
|
сознательно».
|
||||||
|
- `−` правило распространяется на будущие проверки: узел, чья работа и есть
|
||||||
|
обращение к внешней программе, придётся проверять иначе, и чем — здесь не
|
||||||
|
решено.
|
||||||
+5
-5
@@ -31,9 +31,9 @@
|
|||||||
|
|
||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
|
||||||
|
|
||||||
Записей нет: канон заведён 2026-08-10, а решения, принятые до него, источника в
|
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
|
||||||
архиве изменений не имеют — сочинять их задним числом правило запрещает.
|
не имеют — сочинять их задним числом правило запрещает. Ближайшие кандидаты
|
||||||
Ближайшие кандидаты назовёт первое же изменение, которое тронет хранилище или
|
назовёт первое же изменение, которое тронет хранилище или вход: замена SQLite на
|
||||||
вход: замена SQLite на PocketBase и вход через OIDC оба проходят триггер
|
PocketBase и вход через OIDC оба подпадают под критерий «дорогой откат».
|
||||||
«дорогой откат».
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@
|
|||||||
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из
|
||||||
этого ещё не решено — в разделе «Открытые вопросы».
|
этого ещё не решено — в разделе «Открытые вопросы».
|
||||||
|
|
||||||
Спеки ещё не заведены: capability ни одной, поведение живёт только в коде.
|
Заведена одна capability — [intake](../openspec/specs/intake/spec.md), и в ней
|
||||||
Первая задача, которая трогает поведение, заводит спеку — до тех пор у темы
|
описан **только приём по HTTP**: его нормируют проверки, написанные задачей
|
||||||
`requirements` нормативного документа нет.
|
`http-handler-tests-never-green` 2026-08-11. Поведение прочих узлов, включая
|
||||||
|
приём из Telegram, по-прежнему живёт только в коде. Задача, которая его трогает,
|
||||||
|
дописывает спеку своей capability.
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
|
|||||||
+38
-6
@@ -55,7 +55,12 @@
|
|||||||
|
|
||||||
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
|
||||||
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
|
||||||
становится неотличим от привычного шума (журнал, запись 2026-08-10).
|
становится неотличим от привычного шума (журнал, запись 2026-08-10);
|
||||||
|
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
|
||||||
|
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним
|
||||||
|
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется
|
||||||
|
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал,
|
||||||
|
запись 2026-08-11).
|
||||||
|
|
||||||
### Типовые ложноположительные
|
### Типовые ложноположительные
|
||||||
|
|
||||||
@@ -154,14 +159,39 @@ API и имя не откатываются обратной правкой по
|
|||||||
|
|
||||||
**Перестали проверять сознательно:**
|
**Перестали проверять сознательно:**
|
||||||
|
|
||||||
Ничего не отключали — проверять пока и не начинали.
|
- `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-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-обработчика ни разу не были зелёными [проскочил]
|
## 2026-08-10 — тесты http-обработчика ни разу не были зелёными [проскочил]
|
||||||
|
|
||||||
@@ -181,6 +211,8 @@ API и имя не откатываются обратной правкой по
|
|||||||
шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
|
шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в
|
||||||
типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
|
типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в
|
||||||
вопросы темы `autotests` — вопрос про изменённый шаг конвейера
|
вопросы темы `autotests` — вопрос про изменённый шаг конвейера
|
||||||
|
- **Закрыт** 2026-08-11: проверки переписаны, `go test ./...` зелёный и из
|
||||||
|
списка объявленных долгов в [CLAUDE.md](../CLAUDE.md) снят
|
||||||
|
|
||||||
## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
|
## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил]
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -16,10 +17,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
|
|
||||||
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
|
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||||
"github.com/doug-martin/goqu/v9"
|
"github.com/doug-martin/goqu/v9"
|
||||||
@@ -31,10 +31,60 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Подставные адаптеры вместо ffprobe и ffmpeg. Проверки судят приём — что
|
||||||
|
// запись сохранена, задача заведена и ответ такой, какой обещан, — а не
|
||||||
|
// способность внешней программы разобрать звук. Внешних программ здесь нет
|
||||||
|
// ни одной, и видно это по списку импортов.
|
||||||
|
|
||||||
|
// stubMetaViewer отдаёт заданную длительность либо заданную ошибку.
|
||||||
|
type stubMetaViewer struct {
|
||||||
|
seconds int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *stubMetaViewer) GetInfo(string) (*contract.AudioInfo, error) {
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
return &contract.AudioInfo{Seconds: m.seconds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubConverter молчалив: приём конвертацию не делает, и ни одна проверка
|
||||||
|
// этого файла её не зовёт.
|
||||||
|
type stubConverter struct{}
|
||||||
|
|
||||||
|
func (c *stubConverter) Convert(string, string) error { return nil }
|
||||||
|
|
||||||
|
// TestTgSender: приём по HTTP в Telegram не отвечает, но сервису отправитель нужен.
|
||||||
|
type TestTgSender struct{}
|
||||||
|
|
||||||
|
func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readableMetaViewer — источник метаданных, который читает любую запись.
|
||||||
|
func readableMetaViewer() *stubMetaViewer {
|
||||||
|
return &stubMetaViewer{seconds: 42}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testEnv — собранное окружение одной проверки. Каталог хранения свой у
|
||||||
|
// каждой: рабочий каталог процесса проверки не трогают.
|
||||||
|
type testEnv struct {
|
||||||
|
router *gin.Engine
|
||||||
|
handler *TranscribeHandler
|
||||||
|
db *sql.DB
|
||||||
|
storageDir string
|
||||||
|
}
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
||||||
// Создаем временную базу данных в памяти
|
|
||||||
db, err := sql.Open("sqlite3", ":memory:")
|
db, err := sql.Open("sqlite3", ":memory:")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
// Каждому новому соединению с `:memory:` драйвер выдаёт свою базу, и
|
||||||
|
// второй потребитель пула не увидел бы накатанных миграций. Одно
|
||||||
|
// соединение снимает класс целиком.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
gq := goqu.New("sqlite3", db)
|
gq := goqu.New("sqlite3", db)
|
||||||
|
|
||||||
@@ -46,37 +96,39 @@ func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
|||||||
migpath, err := filepath.Abs(path.Join(b, "../../../../migrations"))
|
migpath, err := filepath.Abs(path.Join(b, "../../../../migrations"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
goose.SetLogger(goose.NopLogger())
|
||||||
|
|
||||||
err = goose.Up(db, migpath)
|
err = goose.Up(db, migpath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return db, gq
|
return db, gq
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) {
|
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
db, gq := setupTestDB(t)
|
db, gq := setupTestDB(t)
|
||||||
|
|
||||||
|
storageDir := filepath.Join(t.TempDir(), "files")
|
||||||
|
require.NoError(t, os.MkdirAll(storageDir, 0o755))
|
||||||
|
|
||||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||||
|
|
||||||
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
|
// Журнал проверкам не нужен: судят они по ответу и по базе. А ветка отказа
|
||||||
converter := ffmpegconv.NewFfmpegConverter()
|
// метаданных теперь проходится нарочно, и её ERROR-строки на зелёном
|
||||||
recognizer := &recognizer.MemoryAudioRecognizer{}
|
// прогоне размывали бы признак, по которому отличают новый красный шаг
|
||||||
|
// гейта от объявленного долга.
|
||||||
// Создаем тестовый логгер
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
|
||||||
Level: slog.LevelError, // Только ошибки в тестах
|
|
||||||
}))
|
|
||||||
|
|
||||||
trsService := service.NewTranscribeService(
|
trsService := service.NewTranscribeService(
|
||||||
jobRepo,
|
jobRepo,
|
||||||
fileRepo,
|
fileRepo,
|
||||||
metaviewer,
|
metaviewer,
|
||||||
converter,
|
&stubConverter{},
|
||||||
recognizer,
|
&recognizer.MemoryAudioRecognizer{},
|
||||||
&TestTgSender{},
|
&TestTgSender{},
|
||||||
"data/files",
|
storageDir,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,163 +143,168 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) {
|
|||||||
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
return router, handler
|
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMultipartRequest(t *testing.T, audioFilePath string) (*http.Request, string) {
|
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
||||||
// Открываем тестовый аудио файл
|
// для этого не нужно: имя проверяет выбор расширения, содержимое — сохранение.
|
||||||
file, err := os.Open(audioFilePath)
|
func createMultipartRequest(t *testing.T, fileName string, content []byte) *http.Request {
|
||||||
require.NoError(t, err)
|
return createMultipartRequestWithField(t, "audio", fileName, content)
|
||||||
defer file.Close()
|
}
|
||||||
|
|
||||||
// Создаем буфер для multipart формы
|
// createMultipartRequestWithField кладёт запись в поле с заданным именем —
|
||||||
|
// нужно, чтобы построить форму без поля `audio`.
|
||||||
|
func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
writer := multipart.NewWriter(&buf)
|
writer := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
// Создаем поле для файла
|
part, err := writer.CreateFormFile(field, fileName)
|
||||||
part, err := writer.CreateFormFile("audio", filepath.Base(audioFilePath))
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Копируем содержимое файла
|
_, err = part.Write(content)
|
||||||
_, err = io.Copy(part, file)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Закрываем writer
|
|
||||||
err = writer.Close()
|
err = writer.Close()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Создаем HTTP запрос
|
|
||||||
req, err := http.NewRequest("POST", "/api/audio", &buf)
|
req, err := http.NewRequest("POST", "/api/audio", &buf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
return req, writer.FormDataContentType()
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedFiles отдаёт содержимое каталога хранения.
|
||||||
|
func storedFiles(t *testing.T, env *testEnv) []string {
|
||||||
|
files, err := filepath.Glob(filepath.Join(env.storageDir, "*"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// countJobs считает заведённые задачи расшифровки.
|
||||||
|
func countJobs(t *testing.T, env *testEnv) int {
|
||||||
|
var count int
|
||||||
|
err := env.db.QueryRow("SELECT COUNT(*) FROM transcribe_jobs").Scan(&count)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedFileName отдаёт имя файла, записанное в учёте под данным идентификатором.
|
||||||
|
func storedFileName(t *testing.T, env *testEnv, fileID string) string {
|
||||||
|
var name string
|
||||||
|
err := env.db.QueryRow("SELECT file_name FROM files WHERE id = ?", fileID).Scan(&name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTranscribeJob_Success(t *testing.T) {
|
func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||||
// Создаем временную директорию для файлов
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Создаем структуру директорий для тестов
|
content := []byte("содержимое записи, которое обязано доехать до диска целиком")
|
||||||
testDataDir := filepath.Join(tempDir, "data", "files")
|
req := createMultipartRequest(t, "sample.m4a", content)
|
||||||
err := os.MkdirAll(testDataDir, 0755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Временно меняем рабочую директорию для сохранения файлов
|
|
||||||
originalWd, err := os.Getwd()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.Chdir(originalWd)
|
|
||||||
|
|
||||||
err = os.Chdir(tempDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
router, _ := setupTestRouter(t)
|
|
||||||
|
|
||||||
// Копируем тестовый файл во временную директорию
|
|
||||||
srcFile := filepath.Join(originalWd, "testdata", "sample.m4a")
|
|
||||||
dstFile := "sample.m4a"
|
|
||||||
|
|
||||||
src, err := os.Open(srcFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
dst, err := os.Create(dstFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer dst.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(dst, src)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.Remove(dstFile)
|
|
||||||
|
|
||||||
// Создаем запрос с тестовым аудио файлом
|
|
||||||
req, _ := createMultipartRequest(t, dstFile)
|
|
||||||
|
|
||||||
// Выполняем запрос
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
// Проверяем результат
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
assert.Equal(t, http.StatusCreated, w.Code)
|
|
||||||
|
// Имена полей ответа нормативны: контракт HTTP API объявлен необратимым.
|
||||||
|
// Судим по сырому JSON — разбор в CreateTranscribeJobResponse переименовал
|
||||||
|
// бы тег вместе с ожиданием, и проверка не смогла бы упасть.
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, raw, "job_id")
|
||||||
|
assert.Contains(t, raw, "status")
|
||||||
|
|
||||||
var response CreateTranscribeJobResponse
|
var response CreateTranscribeJobResponse
|
||||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Проверяем, что возвращается корректный ответ
|
|
||||||
assert.NotEmpty(t, response.JobID)
|
assert.NotEmpty(t, response.JobID)
|
||||||
assert.Equal(t, entity.StateCreated, response.State)
|
assert.Equal(t, entity.StateCreated, response.State)
|
||||||
|
|
||||||
// Проверяем, что файл был сохранен
|
// Задача действительно заведена, а не только названа в ответе: иначе
|
||||||
files, err := filepath.Glob(filepath.Join("data", "files", "*"))
|
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||||
require.NoError(t, err)
|
require.Equal(t, 1, countJobs(t, env))
|
||||||
assert.Len(t, files, 1)
|
|
||||||
|
|
||||||
// Проверяем размер сохраненного файла
|
job, err := env.handler.jobRepo.GetByID(response.JobID)
|
||||||
fileInfo, err := os.Stat(files[0])
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Greater(t, fileInfo.Size(), int64(0))
|
assert.Equal(t, entity.StateCreated, job.State)
|
||||||
|
require.NotNil(t, job.FileID)
|
||||||
|
assert.NotEmpty(t, *job.FileID)
|
||||||
|
|
||||||
|
// Содержимое лежит в каталоге хранения одним файлом и целиком.
|
||||||
|
files := storedFiles(t, env)
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
stored, err := os.ReadFile(files[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, content, stored)
|
||||||
|
|
||||||
|
// Учёт указывает на этот самый файл, а не на какой-то другой: дальше по
|
||||||
|
// конвейеру путь берётся только из учёта, и разъезд убил бы задачу молча.
|
||||||
|
assert.Equal(t, filepath.Base(files[0]), storedFileName(t, env, *job.FileID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||||
router, _ := setupTestRouter(t)
|
// Две ветки одного сценария: тела нет вовсе и форма есть, а поля в ней нет.
|
||||||
|
// Вторая — та, что описана требованием; первая ходит тем же путём.
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
req func(t *testing.T) *http.Request
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no body at all",
|
||||||
|
req: func(t *testing.T) *http.Request {
|
||||||
|
req, err := http.NewRequest("POST", "/api/audio", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "form without audio field",
|
||||||
|
req: func(t *testing.T) *http.Request {
|
||||||
|
return createMultipartRequestWithField(t, "attachment", "sample.m4a", []byte("запись"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем запрос без файла
|
for _, tc := range testCases {
|
||||||
req, err := http.NewRequest("POST", "/api/audio", nil)
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
// Выполняем запрос
|
w := httptest.NewRecorder()
|
||||||
w := httptest.NewRecorder()
|
env.router.ServeHTTP(w, tc.req(t))
|
||||||
router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
// Проверяем результат
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
|
||||||
|
|
||||||
var response map[string]string
|
var response map[string]string
|
||||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "No audio file provided", response["error"])
|
assert.Equal(t, "No audio file provided", response["error"])
|
||||||
|
assert.Empty(t, storedFiles(t, env))
|
||||||
|
assert.Equal(t, 0, countJobs(t, env))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
||||||
// Создаем временную директорию для файлов
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Создаем структуру директорий для тестов
|
// Собственного порога по размеру у приёма нет: годность записи судит
|
||||||
testDataDir := filepath.Join(tempDir, "data", "files")
|
// источник метаданных, а не приём.
|
||||||
err := os.MkdirAll(testDataDir, 0755)
|
req := createMultipartRequest(t, "empty.m4a", nil)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Временно меняем рабочую директорию для сохранения файлов
|
|
||||||
originalWd, err := os.Getwd()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.Chdir(originalWd)
|
|
||||||
|
|
||||||
err = os.Chdir(tempDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
router, _ := setupTestRouter(t)
|
|
||||||
|
|
||||||
// Создаем пустой временный файл в текущей директории теста
|
|
||||||
emptyFile := "empty.m4a"
|
|
||||||
f, err := os.Create(emptyFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
f.Close()
|
|
||||||
defer os.Remove(emptyFile)
|
|
||||||
|
|
||||||
// Создаем запрос с пустым файлом
|
|
||||||
req, _ := createMultipartRequest(t, emptyFile)
|
|
||||||
|
|
||||||
// Выполняем запрос
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
// Проверяем результат - даже пустой файл должен быть принят
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
assert.Equal(t, http.StatusCreated, w.Code)
|
|
||||||
|
|
||||||
var response CreateTranscribeJobResponse
|
var response CreateTranscribeJobResponse
|
||||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotEmpty(t, response.JobID)
|
assert.NotEmpty(t, response.JobID)
|
||||||
@@ -257,102 +314,96 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
|||||||
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
filename string
|
fileName string
|
||||||
expectExt string
|
expectExt string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "m4a file",
|
name: "m4a file",
|
||||||
filename: "test.m4a",
|
fileName: "test.m4a",
|
||||||
expectExt: ".m4a",
|
expectExt: ".m4a",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mp3 file",
|
name: "mp3 file",
|
||||||
filename: "test.mp3",
|
fileName: "test.mp3",
|
||||||
expectExt: ".mp3",
|
expectExt: ".mp3",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wav file",
|
name: "wav file",
|
||||||
filename: "test.wav",
|
fileName: "test.wav",
|
||||||
expectExt: ".wav",
|
expectExt: ".wav",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "file without extension",
|
name: "file without extension",
|
||||||
filename: "test",
|
fileName: "test",
|
||||||
expectExt: ".audio",
|
expectExt: ".audio",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
// Создаем временную директорию для файлов
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
tempDir := t.TempDir()
|
|
||||||
|
|
||||||
// Создаем структуру директорий для тестов
|
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
||||||
testDataDir := filepath.Join(tempDir, "data", "files")
|
|
||||||
err := os.MkdirAll(testDataDir, 0755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Временно меняем рабочую директорию для сохранения файлов
|
|
||||||
originalWd, err := os.Getwd()
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer os.Chdir(originalWd)
|
|
||||||
|
|
||||||
err = os.Chdir(tempDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
router, _ := setupTestRouter(t)
|
|
||||||
|
|
||||||
// Создаем временный файл с нужным именем в текущей директории теста
|
|
||||||
testFile := tc.filename
|
|
||||||
f, err := os.Create(testFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
f.WriteString("test audio content")
|
|
||||||
f.Close()
|
|
||||||
defer os.Remove(testFile)
|
|
||||||
|
|
||||||
// Создаем запрос
|
|
||||||
req, _ := createMultipartRequest(t, testFile)
|
|
||||||
|
|
||||||
// Выполняем запрос
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
// Проверяем результат
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
assert.Equal(t, http.StatusCreated, w.Code)
|
|
||||||
|
|
||||||
// Проверяем, что файл сохранен с правильным расширением
|
files := storedFiles(t, env)
|
||||||
files, err := filepath.Glob(filepath.Join("data", "files", "*"+tc.expectExt))
|
require.Len(t, files, 1)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, files, 1)
|
// Имя отправителя в хранилище не попадает: имя файла — свой
|
||||||
|
// идентификатор, от отправителя взято только расширение.
|
||||||
|
assert.Equal(t, tc.expectExt, filepath.Ext(files[0]))
|
||||||
|
assert.NotContains(t, filepath.Base(files[0]), tc.fileName)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
|
||||||
|
|
||||||
|
req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
|
var response map[string]string
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Причина отказа принадлежит журналу, а не отправителю.
|
||||||
|
assert.Equal(t, "Failed to create transcibe job", response["error"])
|
||||||
|
assert.NotContains(t, w.Body.String(), "не удалось прочитать запись")
|
||||||
|
|
||||||
|
assert.Equal(t, 0, countJobs(t, env))
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||||
router, handler := setupTestRouter(t)
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
// Создаем тестовую запись в базе данных
|
|
||||||
job := &entity.TranscribeJob{
|
job := &entity.TranscribeJob{
|
||||||
Id: "test-job-id",
|
Id: "test-job-id",
|
||||||
State: entity.StateCreated,
|
State: entity.StateCreated,
|
||||||
|
Source: entity.SourceApi,
|
||||||
FileID: nil,
|
FileID: nil,
|
||||||
IsError: false,
|
IsError: false,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
err := handler.jobRepo.Create(job)
|
err := env.handler.jobRepo.Create(job)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Создаем запрос
|
|
||||||
req, err := http.NewRequest("GET", "/api/status/test-job-id", nil)
|
req, err := http.NewRequest("GET", "/api/status/test-job-id", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Выполняем запрос
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
// Проверяем результат
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
var response GetTranscribeJobResponse
|
var response GetTranscribeJobResponse
|
||||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
@@ -363,19 +414,50 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
|||||||
assert.NotZero(t, response.CreatedAt)
|
assert.NotZero(t, response.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
job := &entity.TranscribeJob{
|
||||||
|
Id: "job-without-text",
|
||||||
|
State: entity.StateCreated,
|
||||||
|
Source: entity.SourceApi,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := env.handler.jobRepo.Create(job)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "/api/status/job-without-text", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// Судим по сырому JSON: пустая строка на месте отсутствующего текста
|
||||||
|
// читается клиентом как «расшифровка пуста», и разобранная структура
|
||||||
|
// эти два случая не различает.
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
err = json.Unmarshal(w.Body.Bytes(), &raw)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, raw, "job_id")
|
||||||
|
assert.Contains(t, raw, "status")
|
||||||
|
assert.Contains(t, raw, "created_at")
|
||||||
|
assert.NotContains(t, raw, "transcription_text")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
||||||
router, _ := setupTestRouter(t)
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
// Создаем запрос с несуществующим ID
|
|
||||||
req, err := http.NewRequest("GET", "/api/status/non-existent-id", nil)
|
req, err := http.NewRequest("GET", "/api/status/non-existent-id", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Выполняем запрос
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
// Проверяем результат
|
require.Equal(t, http.StatusNotFound, w.Code)
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
||||||
|
|
||||||
var response map[string]string
|
var response map[string]string
|
||||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
@@ -383,9 +465,3 @@ func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, "Job not found", response["error"])
|
assert.Equal(t, "Job not found", response["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestTgSender struct{}
|
|
||||||
|
|
||||||
func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-11
|
||||||
@@ -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 остаётся
|
||||||
|
в коде и без требований. → Смягчение: назван в самой спеке. Требование,
|
||||||
|
написанное без проверки, было бы предположением, а не нормой.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Тесты приёма записи по HTTP заведены давно и не проходили ни разу: один просит
|
||||||
|
файл, которого в репозитории нет и по правилам быть не может, остальные скармливают
|
||||||
|
строку «test audio content» настоящему `ffprobe` и ждут успеха. Из-за этого красный
|
||||||
|
`go test` в проекте перестал что-либо значить — настоящий отказ приёма неотличим от
|
||||||
|
привычного шума, и об ошибке в приёме записи мы узнаем не от машины, а от
|
||||||
|
пользователя.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Проверка приёма перестаёт зависеть от внешнего `ffprobe` и от записи, лежащей
|
||||||
|
в репозитории: длительность в тестах даёт подставной источник метаданных.
|
||||||
|
Проверяем свою логику приёма, а не чужую способность разобрать файл.
|
||||||
|
- Появляется проверка отказа: источник метаданных не смог прочитать запись —
|
||||||
|
приём отвечает отказом, а не успехом. Сегодня это поведение не проверено ничем.
|
||||||
|
- Тесты перестают менять рабочий каталог всего процесса: каталог хранения
|
||||||
|
задаётся приёму снаружи, каждому случаю свой временный.
|
||||||
|
- Поведение приёма записывается требованиями: сегодня оно живёт только в коде,
|
||||||
|
и спорить о том, что здесь правильно, не с чем.
|
||||||
|
|
||||||
|
Поведение самого приёма при этом не меняется — меняется только то, чем оно
|
||||||
|
проверяется. Задача про проверки.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `intake`: приём записи от внешней программы по HTTP и опрос готовности —
|
||||||
|
что считается принятой записью, что возвращается в ответ и что происходит,
|
||||||
|
когда запись не удалось прочитать. Приём из Telegram эта спека пока не
|
||||||
|
описывает: его не трогает ни одна проверка этой задачи, а требование,
|
||||||
|
написанное без проверки, — предположение.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
Нет: спек в проекте ещё не заведено.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `internal/controller/http/transcribe_test.go` — переписывается целиком;
|
||||||
|
- сборка сервиса расшифровки в тестах: каталог хранения и источник метаданных
|
||||||
|
подставляются, а не берутся из окружения;
|
||||||
|
- `.golangci.yml` — послабление `errcheck` для тестов снимается, если после
|
||||||
|
правок в тестах не остаётся непроверенных вызовов;
|
||||||
|
- внешних границ, схемы базы, формата файлов на диске и контракта HTTP API
|
||||||
|
изменение не касается.
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Триаж ревью: `fix-http-handler-tests`
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Размер:** малое. **Сложность:** знакомое. **Метка:** `small`.
|
||||||
|
Обоснование разметки (`review-scope`): изменение трогает один тестовый файл и
|
||||||
|
одну строку конфига линтера, поведение сервиса не меняется, отрицательный тест
|
||||||
|
метки (миграция, формат файла на диске, публичный контракт API, имя ключа) не
|
||||||
|
срабатывает — контракт HTTP API спекой **фиксируется**, а не меняется.
|
||||||
|
- **Режим прогона:** по графу. Дизайн — одна стадия (`specs`), код — `autotests`
|
||||||
|
→ `specs` + `code` → триаж.
|
||||||
|
- **Сигнал о заниженной метке:** пришёл от `review-code` — возражений нет, метку
|
||||||
|
`small` проход счёл обоснованной. `review-basics` не запускался (своих тем у
|
||||||
|
проекта нет), второго независимого подтверждения метки нет.
|
||||||
|
- **Состояние гейта (проверено мной на этом прогоне):**
|
||||||
|
- `go test ./...` — **зелёный** (`ok internal/controller/http 0.013s`); это и
|
||||||
|
есть цель изменения;
|
||||||
|
- `golangci-lint run` — **4 замечания, ровно объявленный долг**
|
||||||
|
(`speechkit.go:55`, `main.go:124`, `worker.go:51`, `service/transcribe.go:394`).
|
||||||
|
Ни одного нового, в том числе после снятия исключения `errcheck` для `_test.go`;
|
||||||
|
- критерий приёмки «не зависит от `ffprobe`» подтверждён: собранный
|
||||||
|
`go test -c` бинарь проходит под `env -i PATH=<пустой каталог>`;
|
||||||
|
- критерий «`os.Chdir` не остаётся» подтверждён: `grep -rn 'os.Chdir' internal/` пуст.
|
||||||
|
- Итог: гейт красный **только унаследованным долгом**; новых красных шагов нет.
|
||||||
|
|
||||||
|
### План разметки с исходом по каждой теме
|
||||||
|
|
||||||
|
| тема | дом | глубина | закрывает | исход |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| requirements | `openspec/changes/fix-http-handler-tests/specs/intake/spec.md` | сверка | `specs` | **закрыта**, 3 находки (потолок 3/3 сработал), 2 из них починены и мной перепроверены мутацией |
|
||||||
|
| autotests | `CLAUDE.md` § Гейт | — | `autotests` | **закрыта**, 0 находок; отчёт о гейте + 1 строка в границы покрытия. О своём потолке проход не сообщил |
|
||||||
|
| conventions | `docs/conventions/README.md` (на `small` — только README) | сверка | `code` | **закрыта**, 1 находка (потолок 1/2), **не починена** — единственный блокер ниже |
|
||||||
|
| architecture | `CLAUDE.md` § Инварианты | сверка | `code` | **закрыта**, 0 находок (потолок инвариантов 0/1) |
|
||||||
|
| security | `CLAUDE.md` § Инварианты | сверка | `code` | **закрыта**, 0 находок — но см. предупреждение ниже |
|
||||||
|
| operations | `CLAUDE.md` § Инварианты | сверка | `code` | **закрыта**, 0 находок |
|
||||||
|
|
||||||
|
Тем без отчёта нет. Тем без дома нет.
|
||||||
|
|
||||||
|
**Предупреждение по теме `security`.** «0 находок» здесь не значит «чисто».
|
||||||
|
Нарушение `critical`-инварианта «содержимое записи остаётся приватным»
|
||||||
|
(`internal/service/transcribe.go:107` пишет `"file_name", fileName` — имя файла
|
||||||
|
пользователя — в журнал) найдено **на стадии дизайна** и **сознательно отложено
|
||||||
|
решением человека на чекпоинте**, поэтому проход кода вернул пустой итог: находка
|
||||||
|
уже известна и вынесена. Она в урожае, не в блокерах, и это решение человека, а не
|
||||||
|
моё. Тот же путь проходят записи из Telegram.
|
||||||
|
|
||||||
|
### Арифметика
|
||||||
|
|
||||||
|
- **На входе:** 14 позиций — 7 именованных находок (`specs` 3, `code` 4),
|
||||||
|
3 названные ниже потолка, 3 отложенные решением человека, 1 замечание о
|
||||||
|
непокрытом конвейере от `autotests`.
|
||||||
|
- **После дедупликации, добычи оракулов и отсева:** **2** позиции, требующие
|
||||||
|
действия (1 блокер + 1 развилка). Остальное — в урожай, гипотезы и promote,
|
||||||
|
ничего не выброшено молча.
|
||||||
|
- Починенное проверено мной независимо: обе `major`-находки `specs` закрыты,
|
||||||
|
мутации их роняют (оракулы ниже).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
### `CLAUDE.md` продолжает объявлять долгом отказ, которого больше нет, — и следующий настоящий отказ тестов приёма спишут на него молча
|
||||||
|
|
||||||
|
- Файл: `CLAUDE.md:96-101`; сопутствующее: `tasks/BACKLOG.md:23`,
|
||||||
|
`tasks/items/http-handler-tests-never-green.md`
|
||||||
|
- Severity: major
|
||||||
|
- Confidence: high
|
||||||
|
- Действие: **инлайн**
|
||||||
|
- Оракул (мой, на этом прогоне):
|
||||||
|
- дословно `CLAUDE.md:98-101`: «`go test ./...` падает в
|
||||||
|
`internal/controller/http`: тесты требуют `testdata/sample.m4a`, которого в
|
||||||
|
репозитории нет и не было… Заведено задачей `http-handler-tests-never-green`»;
|
||||||
|
- `go test ./...` → `ok git.vakhrushev.me/av/transcriber/internal/controller/http 0.013s`;
|
||||||
|
- дословно `CLAUDE.md` § Работа: «Два объявленных долга из раздела „Гейт“
|
||||||
|
сломанным состоянием **не** считаются, пока их не закрыли задачами».
|
||||||
|
- Последствие: после мерджа проект будет письменно утверждать, что красный
|
||||||
|
`go test` в `internal/controller/http` — это норма. Ровно этот механизм записан в
|
||||||
|
журнале дефектов (`docs/review.md`, запись 2026-08-10): «тест, который никогда
|
||||||
|
не проходил, обнуляет сигнал всего пакета: настоящий отказ в нём становится
|
||||||
|
неотличим от привычного шума». Изменение восстанавливает сигнал в коде и
|
||||||
|
оставляет его выключенным в документе, по которому судят «сломано ли». Отказ
|
||||||
|
будет молчаливым: никто не станет разбираться в отказе, объявленном известным.
|
||||||
|
- Предложение: убрать первый из двух известных отказов в `CLAUDE.md` § Гейт
|
||||||
|
(остаётся только `golangci-lint`), закрыть задачу штатным путём каталога
|
||||||
|
(`tasks.py close` — реализованные в `REJECTED.md` не идут, у них есть коммит),
|
||||||
|
снять строку из `tasks/BACKLOG.md`. Задача `tasks.md` этого шага не содержит —
|
||||||
|
добавить его в чек-лист.
|
||||||
|
- Найдено проходом: `review-code`/конвенции; оракул и провенанс — триаж.
|
||||||
|
- Почему блокер, а не «стоит исправить»: `CLAUDE.md` § Работа — единственное
|
||||||
|
место, где записано, что считается сломанным. Пока оно врёт, определение
|
||||||
|
сделанного у следующей задачи опирается на неверный список. Правка
|
||||||
|
механическая, путь документирован, цена — минуты.
|
||||||
|
|
||||||
|
**Замечание о разделении обязанностей.** Общая согласованность документов между
|
||||||
|
собой и с кодом — работа скилла `av-dev-docs:healthcheck`, а не ревью
|
||||||
|
(`CLAUDE.md` § Гейт говорит это прямо). Здесь исключение узкое и обосновано: речь
|
||||||
|
не о дрейфе документации вообще, а о том, что **это самое изменение** закрывает
|
||||||
|
долг, поимённо перечисленный в `CLAUDE.md`, и без правки определение «сломано»
|
||||||
|
становится ложным в момент мерджа.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### Переименование маршрута `POST /api/audio` в `main.go` уедет зелёным: тест ходит по своей копии регистрации
|
||||||
|
|
||||||
|
- Файл: `main.go:198-202` против `internal/controller/http/transcribe_test.go:140-144`
|
||||||
|
- Severity: minor
|
||||||
|
- Confidence: high
|
||||||
|
- Действие: **развилка**
|
||||||
|
- Оракул (мой, мутация в копии дерева, `/tmp/.../scratchpad/mut`):
|
||||||
|
`api.POST("/audio", …)` → `api.POST("/upload", …)` в `main.go`;
|
||||||
|
`go build ./...` проходит, `go test ./internal/controller/http/ -count=1` →
|
||||||
|
`ok … 0.017s`. Тест зелёный при сломанном контракте.
|
||||||
|
Для сравнения — то, что теперь ловится: переименование тега
|
||||||
|
`json:"job_id"` → `json:"jobId"` роняет `TestCreateTranscribeJob_Success`
|
||||||
|
(«does not contain "job_id"»), удаление `s.jobRepo.Create(job)` роняет его же.
|
||||||
|
- Последствие: имена полей ответа изменение защитило (это и была починенная
|
||||||
|
`major`-находка), а путь маршрута — часть того же публичного контракта HTTP API,
|
||||||
|
объявленного в `CLAUDE.md` **необратимым**, — остался незащищённым. Внешняя
|
||||||
|
программа сломается молча, машина промолчит. Вероятность невысока (мутация
|
||||||
|
видна в диффе `main.go`), но класс тот же самый.
|
||||||
|
- Развилка для человека — правка трогает продуктовый код, а `design.md` объявил
|
||||||
|
это Non-Goal («код `internal/service` и `internal/controller/http` не правится»):
|
||||||
|
1. **Вынести регистрацию маршрутов** в экспортируемую функцию пакета
|
||||||
|
`internal/controller/http` (например, `RegisterRoutes(r gin.IRouter, h *TranscribeHandler)`),
|
||||||
|
звать её из `main.go` и из сборки теста. Цена: ~10 строк продуктового кода,
|
||||||
|
выход за объявленный Non-Goal задачи, зато контракт маршрута закрыт машиной.
|
||||||
|
2. **Оставить как есть**, записать в урожай отдельной задачей. Цена: контракт
|
||||||
|
маршрута остаётся на человеке до следующей задачи, которая и так трогает
|
||||||
|
`main.go` (например, `json-api-for-spa`).
|
||||||
|
3. **Оставить как есть и не заводить задачу**, приняв, что маршрут проверяется
|
||||||
|
глазами. Цена: класс дефекта известен, но не записан нигде — при следующем
|
||||||
|
промахе оракула не будет.
|
||||||
|
- Найдено проходом: `review-specs` (там — `minor`, «цена исправления выше цены
|
||||||
|
дефекта»); оракул мутацией — триаж.
|
||||||
|
|
||||||
|
Второго пункта в этой секции нет: остальное либо починено и перепроверено, либо
|
||||||
|
не имеет цены, оправдывающей правку (см. «Отсеяно» и «Урожай»).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
### Понижено оракулом: «зелёный прогон печатает ERROR-строки в stderr» — заявленного последствия нет
|
||||||
|
|
||||||
|
- Исходно: `review-code`/техника, `minor`. Часть починена (логгер сборки теста
|
||||||
|
уведён в `io.Discard`), остаток — `log.Printf("Err: %v", err)` в
|
||||||
|
`internal/controller/http/transcribe.go:45`.
|
||||||
|
- Мой оракул: `go test ./internal/controller/http/ -count=1 2>&1 | grep -c 'Err:'`
|
||||||
|
→ **0**. `go test` буферизует вывод пакета и на успехе его не печатает. Строка
|
||||||
|
видна только под `-v` или при прямом запуске собранного бинаря — то есть на
|
||||||
|
зелёном `task gate` признак ничем не размывается.
|
||||||
|
- Итог: последствие в формулировке находки не воспроизводится, находка снята.
|
||||||
|
Остаётся факт «продуктовый код пишет мимо `slog`» — он **уже записан** в
|
||||||
|
`docs/conventions/logging.md:161` как расхождение, новой находкой не является,
|
||||||
|
ушёл в promote (механизация правила).
|
||||||
|
|
||||||
|
### Понижено: «база `:memory:` без ограничения пула»
|
||||||
|
|
||||||
|
- Исходно: `review-code`/техника, `minor`, `Confidence: low`, «сегодня не
|
||||||
|
срабатывает». Оракула, показывающего отказ, нет ни у прохода, ни у меня.
|
||||||
|
Починка (`db.SetMaxOpenConns(1)`, одна строка, с комментарием почему) уже
|
||||||
|
внесена, безвредна и оставлена как есть. Действия не требует.
|
||||||
|
|
||||||
|
### Не понижалось, но перепроверено: две `major`-находки `specs`
|
||||||
|
|
||||||
|
Обе были заявлены с оракулом и обе починены. Я не поверил на слово и повторил
|
||||||
|
мутации в копии дерева — см. оракул в секции «Стоит исправить сейчас». Обе
|
||||||
|
мутации теперь роняют тест. Находки закрыты.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отсеяно
|
||||||
|
|
||||||
|
- **Мёртвая строка `router.MaxMultipartMemory` в сборке теста**
|
||||||
|
(`transcribe_test.go:138`, названо `review-code` ниже потолка). Проверено:
|
||||||
|
гиновский `MaxMultipartMemory` читается только в `c.FormFile`/`c.MultipartForm`,
|
||||||
|
а обработчик зовёт `c.Request.FormFile`, который использует собственный
|
||||||
|
`defaultMaxMemory` = 32 MiB — то же число. Поведение не меняется ни в тесте, ни
|
||||||
|
в `main.go`, стоимость следующего изменения не растёт, записанной конвенции нет.
|
||||||
|
Выброшено, а не смягчено.
|
||||||
|
- **Проектных ложноположительных (`docs/review.md` → «Типовые
|
||||||
|
ложноположительные», 4 пункта) в выводах не оказалось ни одного.** Ближайший
|
||||||
|
сосед — «Файлы и объекты не удаляются, диск растёт» — к отложенной находке про
|
||||||
|
файл-сироту **не относится**: та запись про отсутствие срока хранения, а
|
||||||
|
находка — про файл, на который нет ни задачи, ни записи в учёте. По этому
|
||||||
|
пункту ничего не отсеяно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
1. **Имена полей публичного ответа судятся по сырому JSON, а не по разобранной
|
||||||
|
структуре.** Приём в разобранную структуру переименовывает тег вместе с
|
||||||
|
ожиданием, и проверка теряет способность упасть — это ровно та `major`, что
|
||||||
|
нашлась здесь. Приём (`map[string]json.RawMessage` + `assert.Contains`)
|
||||||
|
сработал дважды в одном файле. Дома у правила пока нет: в `docs/conventions/`
|
||||||
|
файла про тесты нет. Кандидат в новый раздел конвенций.
|
||||||
|
2. **Стандартный `log` в продуктовом коде — механизировать, а не помнить.**
|
||||||
|
`docs/conventions/logging.md` уже пишет «**Механизировано:** ничего. Ни
|
||||||
|
`sloglint`, ни `forbidigo` в `.golangci.yml` не заведено» и поимённо называет
|
||||||
|
расхождение `internal/controller/http/transcribe.go`. Правило записано и
|
||||||
|
механизируемо, значит это не находка ревью, а `Promote candidate`: `forbidigo`
|
||||||
|
на `log.` в `.golangci.yml`.
|
||||||
|
3. **Регистрация маршрутов — одна на процесс и на тест** (производное от развилки
|
||||||
|
выше; актуально, если человек выберет вариант 2 или 3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Урожай
|
||||||
|
|
||||||
|
Формулировка → оракул → провенанс. Ничего из этого не чинится в этом изменении.
|
||||||
|
|
||||||
|
1. **Приём пишет имя файла пользователя в журнал — нарушение `critical`-инварианта.**
|
||||||
|
`internal/service/transcribe.go:107`:
|
||||||
|
`s.logger.Info("Creating transcribe job", "file_id", …, "file_name", fileName, …)`.
|
||||||
|
Оракул: дословно `CLAUDE.md` § Инварианты — «Содержимое записи остаётся
|
||||||
|
приватным. Текст расшифровки, **имя файла пользователя** и его сообщение в лог
|
||||||
|
не пишутся — только длина и идентификаторы. Нарушение необратимо: строки уже
|
||||||
|
уехали в журнал контейнера. **critical**». Плюс вопрос темы `security` в
|
||||||
|
`docs/review.md`. Путь общий с Telegram, то есть касается живых записей.
|
||||||
|
Провенанс: ревью дизайна; **отложено решением человека на чекпоинте**,
|
||||||
|
записано в `design.md` § Risks. Спека `intake` нормирует только хранилище и о
|
||||||
|
журнале молчит намеренно.
|
||||||
|
2. **Отказ чтения метаданных оставляет файл на диске без уборки.**
|
||||||
|
`internal/service/transcribe.go:129-133` (ветка `metaviewer.GetInfo`) против
|
||||||
|
`152-157` (ветка `fileRepo.Create`, где `os.Remove` есть). Оракул: чтение кода;
|
||||||
|
ни задачи, ни записи в учёте под такой файл нет — сопоставить его не с чем.
|
||||||
|
Провенанс: ревью дизайна; отложено решением человека (правка трогает три ветки
|
||||||
|
отказа и меняет поведение на диске).
|
||||||
|
3. **Настоящий `ffprobe` после этой правки не проверяется ничем.**
|
||||||
|
У `internal/adapter/metaviewer/ffmpeg` своего теста нет; разбор вывода
|
||||||
|
`ffprobe` не покрыт. Оракул: `go test ./...` → `? …/adapter/metaviewer/ffmpeg
|
||||||
|
[no test files]`. Провенанс: `design.md` § Risks, названо прямо. Формально
|
||||||
|
покрытие не потеряно — прежний тест проверял отказ `ffprobe` и выдавал его за
|
||||||
|
проверку приёма.
|
||||||
|
4. **Конвейер задач не покрыт ни одним тестом.**
|
||||||
|
`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
|
||||||
|
`FindAndRunTranscribeCheckJob`, `internal/controller/worker`. Три воркера
|
||||||
|
читают общий `*sql.DB`, теста с параллельным доступом нет. Оракул:
|
||||||
|
`go test ./...` → `[no test files]` у `internal/service` и
|
||||||
|
`internal/controller/worker`. Провенанс: `autotests`, вне scope задачи.
|
||||||
|
Совпадает со свойством, добавленным в `docs/review.md` («изменённое место
|
||||||
|
покрыто хоть одним **проходящим** тестом») и с вопросом темы `autotests`.
|
||||||
|
5. **Опечатка `transcibe` в тексте ошибки теперь закреплена проверкой.**
|
||||||
|
`internal/controller/http/transcribe.go:46` и
|
||||||
|
`transcribe_test.go:379` — `"Failed to create transcibe job"`. Оракул: обе
|
||||||
|
строки дословно. Исправление меняет тело ответа, то есть **публичный контракт
|
||||||
|
HTTP API**, объявленный в `CLAUDE.md` необратимым, — значит спрашивается у
|
||||||
|
человека и не делается походя. Провенанс: `review-specs`, ниже потолка.
|
||||||
|
Действия сейчас не требует: статус-кво зафиксирован сознательно.
|
||||||
|
6. **Требование «имя отправителя не попадает в хранилище» проверяется только
|
||||||
|
благополучными именами.** Проверено мной попутно (вопрос темы `security` из
|
||||||
|
`docs/review.md`: «не строится ли путь на диске из значения, пришедшего
|
||||||
|
снаружи»): обхода каталога нет **по построению** — `filepath.Ext` не
|
||||||
|
пересекает разделитель пути, и на входах `../../../etc/passwd.m4a`,
|
||||||
|
`evil.m4a/../../x`, `/etc/passwd`, `a.b/../../c`, `..` результат всегда
|
||||||
|
`<uuid><ext>` внутри каталога хранения (оракул: прогон `filepath.Ext` +
|
||||||
|
`filepath.Join` на этих восьми входах, вывод снят на этом прогоне). Дефекта
|
||||||
|
нет; недостающее — сторожевой случай, который зафиксирует это свойство.
|
||||||
|
Провенанс: триаж.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
### План: темы, дома, глубины
|
||||||
|
|
||||||
|
Полностью воспроизведён в сводке выше вместе с исходом каждой темы. Тем без дома
|
||||||
|
нет, тем без отчёта нет. Дома тем `architecture`, `security` и `operations` —
|
||||||
|
раздел «Инварианты» `CLAUDE.md`, глубина «сверка».
|
||||||
|
|
||||||
|
### Какие проходы запускались
|
||||||
|
|
||||||
|
- Ревью дизайна: `review-specs`, одна стадия, метка `small`.
|
||||||
|
- Ревью кода: `review-autotests` → `review-specs` + `review-code` → триаж. Режим —
|
||||||
|
по графу, метка `small`.
|
||||||
|
- **Не запускался `review-basics`**: своих тем у проекта нет — так сказал план.
|
||||||
|
Следствие названо ниже, в строке про корректор метки.
|
||||||
|
|
||||||
|
### Сработавшие потолки
|
||||||
|
|
||||||
|
- `review-specs` (код): **3 из 3**, потолок сработал. Ниже среза остались
|
||||||
|
названными: литералы сообщений об ошибке, включая опечатку `transcibe`;
|
||||||
|
расхождение сценария «Поля с записью нет» с тем, что делал тест (починено).
|
||||||
|
- `review-code`: **техника 3/3 — сработал**; **конвенции 1/2**; **инварианты 0/1**.
|
||||||
|
Ниже среза осталась названной мёртвая строка `router.MaxMultipartMemory`.
|
||||||
|
- `review-autotests`: **о своём потолке не сообщил**. Это находка о прогоне: по
|
||||||
|
контракту проход обязан сказать, сколько нашёл, каков был потолок и что
|
||||||
|
осталось за срезом. Судить, есть ли за его срезом что-то ещё, нечем.
|
||||||
|
- Триаж: потолок 3/4 не исчерпан (1 блокер, 1 в «стоит исправить»). Из-за потолка
|
||||||
|
**ничего не выброшено**.
|
||||||
|
|
||||||
|
### Что каждый запущенный проход не мог проверить в принципе
|
||||||
|
|
||||||
|
Ниже — по отчётам проходов; charter'ы агентов мне дословно не подавались, поэтому
|
||||||
|
это пересказ их собственных заявлений, а не цитата устава.
|
||||||
|
|
||||||
|
- `review-autotests`: судит наличие и зелёность проверок, а не правильность
|
||||||
|
нормы, которую они проверяют. Прогнал `go test` 5× подряд и с `-race` — флаки
|
||||||
|
не обнаружен; это отсутствие сигнала на пяти прогонах, а не доказательство
|
||||||
|
детерминированности.
|
||||||
|
- `review-specs`: судит соответствие кода дельта-спеке; правильность самой спеки
|
||||||
|
вне его входа. Приём из Telegram спекой `intake` не описан сознательно — значит,
|
||||||
|
и не проверялся.
|
||||||
|
- `review-code`: на метке `small` конвенции сверялись только с
|
||||||
|
`docs/conventions/README.md`, а `architecture`/`security`/`operations` — только
|
||||||
|
с записанными инвариантами `CLAUDE.md`.
|
||||||
|
- Триаж: **ничего нового не находит по определению**. Я не читаю код в поисках
|
||||||
|
дефектов, я работаю с чужими выводами. Пропуск любого прохода — мой пропуск
|
||||||
|
тоже; всё, что я могу, — назвать его поимённо, что и сделано выше.
|
||||||
|
|
||||||
|
### Что осталось целиком на человеке
|
||||||
|
|
||||||
|
Из `docs/review.md` → «Недоступно проверке», **двумя отдельными списками, как
|
||||||
|
записано**:
|
||||||
|
|
||||||
|
**Не проверит ни один проход:**
|
||||||
|
- `operations`: поведение внешних сервисов под нагрузкой и на границах — SpeechKit
|
||||||
|
и Object Storage поднять в тесте нечем;
|
||||||
|
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
|
||||||
|
день, и утверждения о росте остаются условиями, а не замерами;
|
||||||
|
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||||
|
отдан внешней программе, и она вне нашей границы.
|
||||||
|
|
||||||
|
**Перестали проверять сознательно:**
|
||||||
|
- по записи в `docs/review.md` — «Ничего не отключали: проверять пока и не
|
||||||
|
начинали». **Однако этим изменением список пополняется фактически**: приём
|
||||||
|
перестал проверяться сквозь настоящий `ffprobe` (решение записано в
|
||||||
|
`design.md` § Risks и обосновано — прежняя проверка проверяла отказ внешней
|
||||||
|
программы и выдавала его за проверку приёма). Раздел `docs/review.md` этого
|
||||||
|
ещё не знает; строку туда добавляет синк документации, не я.
|
||||||
|
|
||||||
|
Сверх записанного в проекте — общее, чего не видит ни один прогон: история
|
||||||
|
инцидентов, поведение под реальным потоком, поведение внешних систем в их
|
||||||
|
версиях, завязка потребителей на текущее поведение и вопрос «а нужна ли эта
|
||||||
|
функциональность вообще».
|
||||||
|
|
||||||
|
### Каких документов проекта не хватило
|
||||||
|
|
||||||
|
Строкой на каждый, с причиной — деградация поразрядная:
|
||||||
|
|
||||||
|
- `docs/conventions/` **про тесты файла нет**: конвенции покрывают конфиг, базу,
|
||||||
|
ошибки, журнал и веб-UI. Изменение целиком про тесты, и сверять его форму было
|
||||||
|
не с чем — отсюда promote-кандидат №1, а не находка.
|
||||||
|
- `docs/adr/` **пуст**: только `README.md` и `template.md`, ни одного решения. См.
|
||||||
|
обязательную строку 1 ниже.
|
||||||
|
- `docs/research/` — только `README.md`, записанных замеров нет. См. строку 2.
|
||||||
|
- `docs/review.md` § «Как настроен конвейер» **устарел с этого прогона**: там
|
||||||
|
написано «Конвейера ревью в проекте пока нет: плагин не подключён, ни одного
|
||||||
|
прогона не было». Прогон был — этот. Отсев ложноположительных при этом **не был
|
||||||
|
слепым**: раздел «Типовые ложноположительные» заполнен наперёд, четыре пункта, и
|
||||||
|
я им пользовался.
|
||||||
|
- `docs/security.md` в проекте **есть**, но на метке `small` план отправил тему
|
||||||
|
`security` в инварианты `CLAUDE.md`, и как дом темы `security.md` не
|
||||||
|
открывался. См. строку 5 ниже.
|
||||||
|
|
||||||
|
### Четыре строки, которых не принесёт ни один проход
|
||||||
|
|
||||||
|
1. **Решения проекта не сверялись.** `docs/adr/*` — процессный документ, прогон
|
||||||
|
его не открывает. Расхождение изменения с записанным решением ловит сверка
|
||||||
|
документации (скилл `av-dev-docs:healthcheck`), а не ревью. Здесь у этого есть
|
||||||
|
и вторая сторона: каталог решений пуст, сверять было бы не с чем.
|
||||||
|
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
|
||||||
|
процессный. Всякое число в этом отчёте снято командой на этом прогоне; чисел
|
||||||
|
без приложенной команды в отчёте нет.
|
||||||
|
3. **Поимённая сверка с руководствами по стилю Go не задавалась ни одним
|
||||||
|
проходом.** Различение «идиоматично против просто распространено» на этом
|
||||||
|
прогоне не спрашивал никто.
|
||||||
|
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||||
|
нет.** Проход независимой реализации снят по стоимости, а не по замеру.
|
||||||
|
«Не знаю, чего не знаю» здесь никто не достаёт: например, вопрос «а верна ли
|
||||||
|
сама форма подстановки в сборке теста» не задал никто, кроме автора дизайна.
|
||||||
|
|
||||||
|
### Пятая строка — следствие метки `small`
|
||||||
|
|
||||||
|
Темы `security`, `operations` и `architecture` сверялись **только с записанными
|
||||||
|
инвариантами `CLAUDE.md`**; дома этих тем (`docs/security.md`,
|
||||||
|
`docs/architecture.md`, `docs/conventions/logging.md` как источник норм журнала)
|
||||||
|
не открывались. Свойство, которого нет в семи пунктах инвариантов, на этом
|
||||||
|
прогоне не проверил никто.
|
||||||
|
|
||||||
|
### Отдельно про корректор метки
|
||||||
|
|
||||||
|
`review-code` метку `small` подтвердил, сигнала о занижении не подал.
|
||||||
|
`review-basics` **не запускался**, поэтому второго, независимого от `review-code`
|
||||||
|
подтверждения метки нет. Согласия двух проходов здесь не было бы и при запуске:
|
||||||
|
несколько агентов — один источник, высказавшийся несколько раз; совпадение
|
||||||
|
подняло бы приоритет, но не `confidence`.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Приём записи по HTTP
|
||||||
|
|
||||||
|
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||||
|
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
|
||||||
|
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
|
||||||
|
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
|
||||||
|
|
||||||
|
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
||||||
|
и переименование поля ломает внешнюю программу молча.
|
||||||
|
|
||||||
|
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||||
|
пригодность содержимого узнаёт у источника метаданных.
|
||||||
|
|
||||||
|
#### Scenario: Запись принята
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||||
|
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||||
|
со значением `created`
|
||||||
|
- **AND** содержимое записи целиком лежит в каталоге хранения одним файлом
|
||||||
|
|
||||||
|
#### Scenario: Поля с записью нет
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||||
|
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||||
|
- **AND** ни файла, ни задачи не заводится
|
||||||
|
|
||||||
|
#### Scenario: Размеру записи приём не судья
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт запись нулевой длины
|
||||||
|
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||||
|
|
||||||
|
### Requirement: Имя файла в хранилище
|
||||||
|
|
||||||
|
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
|
||||||
|
к которому приписано расширение из имени файла отправителя. Имя, данное
|
||||||
|
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
|
||||||
|
своим приёму не подконтрольно.
|
||||||
|
|
||||||
|
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
|
||||||
|
файла на диске расширение было всегда.
|
||||||
|
|
||||||
|
#### Scenario: Расширение взято из имени отправителя
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт запись с именем `test.mp3`
|
||||||
|
- **THEN** файл в каталоге хранения имеет расширение `.mp3`
|
||||||
|
|
||||||
|
#### Scenario: Имени без расширения назначено своё
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт запись с именем `test` без расширения
|
||||||
|
- **THEN** файл в каталоге хранения имеет расширение `.audio`
|
||||||
|
|
||||||
|
### Requirement: Отказ чтения метаданных
|
||||||
|
|
||||||
|
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
|
||||||
|
принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в
|
||||||
|
тело ответа: она принадлежит журналу, а не отправителю.
|
||||||
|
|
||||||
|
#### Scenario: Источник метаданных вернул ошибку
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных не может прочитать запись
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с этой записью
|
||||||
|
- **THEN** ответ имеет код `500`
|
||||||
|
- **AND** задача расшифровки не заводится
|
||||||
|
|
||||||
|
### Requirement: Опрос готовности задачи
|
||||||
|
|
||||||
|
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||||
|
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние
|
||||||
|
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
|
||||||
|
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
|
||||||
|
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
|
||||||
|
|
||||||
|
#### Scenario: Задача найдена
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние заведённой задачи
|
||||||
|
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||||
|
|
||||||
|
#### Scenario: Расшифровки ещё нет
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
||||||
|
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||||
|
|
||||||
|
#### Scenario: Задачи с таким идентификатором нет
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||||
|
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## 1. Подстановки в сборке теста
|
||||||
|
|
||||||
|
- [x] 1.1 Завести в тестовом пакете подставной `contract.AudioMetaViewer`: отдаёт
|
||||||
|
заданную длительность либо заданную ошибку
|
||||||
|
- [x] 1.2 Завести подставной `contract.AudioFileConverter` — конвертацию эти
|
||||||
|
проверки не зовут
|
||||||
|
- [x] 1.3 `setupTestRouter` принимает каталог хранения и источник метаданных
|
||||||
|
снаружи; `ffmpegmv` и `ffmpegconv` из импортов пакета уходят
|
||||||
|
- [x] 1.4 Каталог хранения каждому случаю даёт `t.TempDir()`; `os.Chdir` в файле
|
||||||
|
не остаётся ни одного
|
||||||
|
|
||||||
|
## 2. Сборка запроса
|
||||||
|
|
||||||
|
- [x] 2.1 Вспомогательная функция собирает `multipart`-запрос из имени файла и
|
||||||
|
байтов, а не из пути на диске
|
||||||
|
- [x] 2.2 Ни один случай не создаёт файлов в текущем каталоге
|
||||||
|
|
||||||
|
## 3. Проверки приёма
|
||||||
|
|
||||||
|
- [x] 3.1 Приём записи: код `201`, поле `job_id` непустое, поле `status` равно
|
||||||
|
`created`, содержимое целиком лежит в каталоге хранения одним файлом
|
||||||
|
- [x] 3.2 Поля `audio` нет: код `400`, сообщение об отсутствии записи
|
||||||
|
- [x] 3.3 Запись нулевой длины: код `201`
|
||||||
|
- [x] 3.4 Расширение из имени отправителя: `.m4a`, `.mp3`, `.wav`, а имя без
|
||||||
|
расширения даёт `.audio`
|
||||||
|
- [x] 3.5 Источник метаданных вернул ошибку: код `500`, задача не заведена
|
||||||
|
- [x] 3.6 Опрос готовности: найденная задача даёт `200` с полями `job_id`,
|
||||||
|
`status` и `created_at`; неизвестный идентификатор даёт `404`
|
||||||
|
- [x] 3.7 Поля `transcription_text` в ответе нет, пока текста нет — проверяется
|
||||||
|
по сырому JSON, а не по разобранной структуре
|
||||||
|
|
||||||
|
## 4. Линтер
|
||||||
|
|
||||||
|
- [x] 4.1 Снять исключение `errcheck` для `_test.go` в `.golangci.yml`
|
||||||
|
- [x] 4.2 `golangci-lint run` не даёт замечаний сверх четырёх объявленных долгом
|
||||||
|
в `CLAUDE.md`
|
||||||
|
|
||||||
|
## 5. Гейт и приёмка
|
||||||
|
|
||||||
|
- [x] 5.1 `go test ./...` зелёный
|
||||||
|
- [x] 5.2 `task gate` не краснее объявленного долга: из двух известных отказов
|
||||||
|
остаётся только `golangci-lint`
|
||||||
|
- [x] 5.3 Каждый критерий приёмки проверен своим оракулом, исход записан
|
||||||
|
- [x] 5.4 Снять `go test ./...` из списка объявленных долгов в `CLAUDE.md`,
|
||||||
|
раздел «Гейт»: долг закрыт, и оставленная запись стала бы оправданием для
|
||||||
|
любого будущего красного `go test` в этом пакете
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
Дословно из записи задачи `http-handler-tests-never-green`:
|
||||||
|
|
||||||
|
- `go test ./...` зелёный на чистом клоне без ручной подготовки файлов. Оракул —
|
||||||
|
`git clone` во временный каталог и `go test ./...`.
|
||||||
|
- Тест приёма не зависит от установленного `ffprobe`: метаданные даёт подставной
|
||||||
|
`AudioMetaViewer`. Оракул — прогон с временно переименованным `ffprobe` в
|
||||||
|
`PATH`.
|
||||||
|
- Отказ разбора метаданных проверяется отдельным случаем и ожидает `500`, а не
|
||||||
|
`201`. Оракул — тот же тест на подставном, возвращающем ошибку.
|
||||||
|
- Тесты не меняют рабочий каталог процесса. Оракул — `grep -n 'os.Chdir'
|
||||||
|
internal/controller/http/transcribe_test.go` пуст.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# intake Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Приём записи и опрос готовности задачи расшифровки: что считается принятой
|
||||||
|
записью, что уезжает в ответ и что происходит, когда запись не удалось
|
||||||
|
прочитать.
|
||||||
|
|
||||||
|
Описан пока **только приём по HTTP** — тот, что нормируют проверки. Приём из
|
||||||
|
Telegram делит с ним общий шаг заведения задачи, но требований на него нет:
|
||||||
|
требование, написанное без проверки, — предположение, а не норма. Первая задача,
|
||||||
|
которая трогает поведение приёма из Telegram, дописывает его сюда.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Приём записи по HTTP
|
||||||
|
|
||||||
|
Сервис SHALL принимать запись от внешней программы запросом `POST /api/audio` с
|
||||||
|
телом `multipart/form-data` и полем `audio`. Принятая запись MUST быть сохранена
|
||||||
|
и получить заведённую под неё задачу расшифровки в состоянии `created`; ответ
|
||||||
|
MUST нести идентификатор задачи полем `job_id` и её состояние полем `status`.
|
||||||
|
|
||||||
|
Имена полей ответа нормативны: контракт HTTP API объявлен проектом необратимым,
|
||||||
|
и переименование поля ломает внешнюю программу молча.
|
||||||
|
|
||||||
|
Приём не судит о годности записи сам: расширение он берёт из имени файла, а
|
||||||
|
пригодность содержимого узнаёт у источника метаданных.
|
||||||
|
|
||||||
|
#### Scenario: Запись принята
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с полем `audio`
|
||||||
|
- **THEN** ответ имеет код `201`, а в теле лежат непустой `job_id` и `status`
|
||||||
|
со значением `created`
|
||||||
|
- **AND** содержимое записи целиком лежит в каталоге хранения одним файлом
|
||||||
|
|
||||||
|
#### Scenario: Поля с записью нет
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` без поля `audio`
|
||||||
|
- **THEN** ответ имеет код `400` и сообщение об отсутствии записи
|
||||||
|
- **AND** ни файла, ни задачи не заводится
|
||||||
|
|
||||||
|
#### Scenario: Размеру записи приём не судья
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт запись нулевой длины
|
||||||
|
- **THEN** ответ имеет код `201`: собственного порога по размеру у приёма нет
|
||||||
|
|
||||||
|
### Requirement: Имя файла в хранилище
|
||||||
|
|
||||||
|
Сервис SHALL сохранять принятую запись под собственным именем — идентификатором,
|
||||||
|
к которому приписано расширение из имени файла отправителя. Имя, данное
|
||||||
|
отправителем, MUST не попадать в хранилище: оно приходит извне и содержимым
|
||||||
|
своим приёму не подконтрольно.
|
||||||
|
|
||||||
|
Расширения в присланном имени нет — сервис MUST подставить `.audio`, чтобы у
|
||||||
|
файла на диске расширение было всегда.
|
||||||
|
|
||||||
|
#### Scenario: Расширение взято из имени отправителя
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт запись с именем `test.mp3`
|
||||||
|
- **THEN** файл в каталоге хранения имеет расширение `.mp3`
|
||||||
|
|
||||||
|
#### Scenario: Имени без расширения назначено своё
|
||||||
|
|
||||||
|
- **WHEN** программа шлёт запись с именем `test` без расширения
|
||||||
|
- **THEN** файл в каталоге хранения имеет расширение `.audio`
|
||||||
|
|
||||||
|
### Requirement: Отказ чтения метаданных
|
||||||
|
|
||||||
|
Сервис SHALL отвечать отказом, когда источник метаданных не смог прочитать
|
||||||
|
принятую запись. Ответ MUST иметь код `500`, а причина отказа MUST не попадать в
|
||||||
|
тело ответа: она принадлежит журналу, а не отправителю.
|
||||||
|
|
||||||
|
#### Scenario: Источник метаданных вернул ошибку
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных не может прочитать запись
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с этой записью
|
||||||
|
- **THEN** ответ имеет код `500`
|
||||||
|
- **AND** задача расшифровки не заводится
|
||||||
|
|
||||||
|
### Requirement: Опрос готовности задачи
|
||||||
|
|
||||||
|
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||||
|
`GET /api/status/:id`. Ответ MUST нести идентификатор полем `job_id`, состояние
|
||||||
|
полем `status` и время заведения полем `created_at`, а текст расшифровки полем
|
||||||
|
`transcription_text`, и это поле MUST отсутствовать в ответе, пока текста нет:
|
||||||
|
пустая строка на месте отсутствующего текста читается как «расшифровка пуста».
|
||||||
|
|
||||||
|
#### Scenario: Задача найдена
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние заведённой задачи
|
||||||
|
- **THEN** ответ имеет код `200` и несёт `job_id`, `status` и `created_at`
|
||||||
|
|
||||||
|
#### Scenario: Расшифровки ещё нет
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние задачи, которая ещё не дошла до текста
|
||||||
|
- **THEN** поля `transcription_text` в ответе нет вовсе
|
||||||
|
|
||||||
|
#### Scenario: Задачи с таким идентификатором нет
|
||||||
|
|
||||||
|
- **WHEN** программа спрашивает состояние по неизвестному идентификатору
|
||||||
|
- **THEN** ответ имеет код `404` и сообщение о ненайденной задаче
|
||||||
|
|
||||||
Reference in New Issue
Block a user