diff --git a/.golangci.yml b/.golangci.yml index 3758e77..a0fe52e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,11 +15,6 @@ linters: - os.Remove # Метод сам логирует ошибку отправки, вызывающему она не нужна - (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send - exclusions: - rules: - - path: _test\.go - linters: - - errcheck formatters: enable: diff --git a/CLAUDE.md b/CLAUDE.md index 70463c1..8d8c6a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,12 +93,8 @@ task gate # весь набор проверок разом - покрытие изменённых строк не считается ничем. **Гейт на `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 замечания в существующем коде: два непроверенных `Close` (`adapter/recognizer/yandex/speechkit.go:55`, `main.go:124`) и два сравнения ошибок приведением типа (`controller/worker/worker.go:51`, @@ -106,9 +102,13 @@ task gate # весь набор проверок разом [docs/conventions/errors.md](docs/conventions/errors.md), заведён задачей `errors-as-instead-of-typecast`. -Новые отказы отличай от этих. Пока они живы, «зелёный гейт» в определении +Новые отказы отличай от этого. Пока он жив, «зелёный гейт» в определении сделанного означает «не добавилось ничего сверх перечисленного». +**`go test ./...` больше долгом не считается.** Тесты приёма по HTTP чинены +задачей `http-handler-tests-never-green`; красный `go test` теперь означает +поломку, и списывать его на наследство нельзя. + ## Запреты - **Рабочую БД не трогать.** `data/transcriber.db` на сервере и его копии. diff --git a/docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md b/docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md new file mode 100644 index 0000000..43a6162 --- /dev/null +++ b/docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md @@ -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), «Перестали проверять + сознательно». +- `−` правило распространяется на будущие проверки: узел, чья работа и есть + обращение к внешней программе, придётся проверять иначе, и чем — здесь не + решено. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4478a17..ae4a657 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,9 +31,9 @@ | Дата | Запись | Статус | | --- | --- | --- | +| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | | -Записей нет: канон заведён 2026-08-10, а решения, принятые до него, источника в -архиве изменений не имеют — сочинять их задним числом правило запрещает. -Ближайшие кандидаты назовёт первое же изменение, которое тронет хранилище или -вход: замена SQLite на PocketBase и вход через OIDC оба проходят триггер -«дорогой откат». +Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений +не имеют — сочинять их задним числом правило запрещает. Ближайшие кандидаты +назовёт первое же изменение, которое тронет хранилище или вход: замена SQLite на +PocketBase и вход через OIDC оба подпадают под критерий «дорогой откат». diff --git a/docs/architecture.md b/docs/architecture.md index aad1987..4ba3c7b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,9 +8,11 @@ [passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из этого ещё не решено — в разделе «Открытые вопросы». -Спеки ещё не заведены: capability ни одной, поведение живёт только в коде. -Первая задача, которая трогает поведение, заводит спеку — до тех пор у темы -`requirements` нормативного документа нет. +Заведена одна capability — [intake](../openspec/specs/intake/spec.md), и в ней +описан **только приём по HTTP**: его нормируют проверки, написанные задачей +`http-handler-tests-never-green` 2026-08-11. Поведение прочих узлов, включая +приём из Telegram, по-прежнему живёт только в коде. Задача, которая его трогает, +дописывает спеку своей capability. ## Принципы diff --git a/docs/review.md b/docs/review.md index bfbe415..ec499b7 100644 --- a/docs/review.md +++ b/docs/review.md @@ -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-обработчика ни разу не были зелёными [проскочил] @@ -181,6 +211,8 @@ API и имя не откатываются обратной правкой по шире: **тест, который никогда не проходил, обнуляет сигнал всего пакета** — в типовые узлы добавлено свойство «покрыт хоть одним проходящим тестом», а в вопросы темы `autotests` — вопрос про изменённый шаг конвейера +- **Закрыт** 2026-08-11: проверки переписаны, `go test ./...` зелёный и из + списка объявленных долгов в [CLAUDE.md](../CLAUDE.md) снят ## 2025-10-23 — пустой ответ вместо текста расшифровки [проскочил] diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 7dbe689..0543d71 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -4,6 +4,7 @@ import ( "bytes" "database/sql" "encoding/json" + "errors" "io" "log/slog" "mime/multipart" @@ -16,10 +17,9 @@ import ( "testing" "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/repo/sqlite" + "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/service" "github.com/doug-martin/goqu/v9" @@ -31,10 +31,60 @@ import ( "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) { - // Создаем временную базу данных в памяти db, err := sql.Open("sqlite3", ":memory:") require.NoError(t, err) + t.Cleanup(func() { db.Close() }) + + // Каждому новому соединению с `:memory:` драйвер выдаёт свою базу, и + // второй потребитель пула не увидел бы накатанных миграций. Одно + // соединение снимает класс целиком. + db.SetMaxOpenConns(1) 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")) require.NoError(t, err) + goose.SetLogger(goose.NopLogger()) + err = goose.Up(db, migpath) require.NoError(t, err) return db, gq } -func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) { +func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { gin.SetMode(gin.TestMode) db, gq := setupTestDB(t) + storageDir := filepath.Join(t.TempDir(), "files") + require.NoError(t, os.MkdirAll(storageDir, 0o755)) + fileRepo := sqlite.NewFileRepository(db, gq) jobRepo := sqlite.NewTranscriptJobRepository(db, gq) - metaviewer := ffmpegmv.NewFfmpegMetaViewer() - converter := ffmpegconv.NewFfmpegConverter() - recognizer := &recognizer.MemoryAudioRecognizer{} - - // Создаем тестовый логгер - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelError, // Только ошибки в тестах - })) + // Журнал проверкам не нужен: судят они по ответу и по базе. А ветка отказа + // метаданных теперь проходится нарочно, и её ERROR-строки на зелёном + // прогоне размывали бы признак, по которому отличают новый красный шаг + // гейта от объявленного долга. + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) trsService := service.NewTranscribeService( jobRepo, fileRepo, metaviewer, - converter, - recognizer, + &stubConverter{}, + &recognizer.MemoryAudioRecognizer{}, &TestTgSender{}, - "data/files", + storageDir, logger, ) @@ -91,163 +143,168 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) { 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) { - // Открываем тестовый аудио файл - file, err := os.Open(audioFilePath) - require.NoError(t, err) - defer file.Close() +// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске +// для этого не нужно: имя проверяет выбор расширения, содержимое — сохранение. +func createMultipartRequest(t *testing.T, fileName string, content []byte) *http.Request { + return createMultipartRequestWithField(t, "audio", fileName, content) +} - // Создаем буфер для multipart формы +// createMultipartRequestWithField кладёт запись в поле с заданным именем — +// нужно, чтобы построить форму без поля `audio`. +func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request { var buf bytes.Buffer writer := multipart.NewWriter(&buf) - // Создаем поле для файла - part, err := writer.CreateFormFile("audio", filepath.Base(audioFilePath)) + part, err := writer.CreateFormFile(field, fileName) require.NoError(t, err) - // Копируем содержимое файла - _, err = io.Copy(part, file) + _, err = part.Write(content) require.NoError(t, err) - // Закрываем writer err = writer.Close() require.NoError(t, err) - // Создаем HTTP запрос req, err := http.NewRequest("POST", "/api/audio", &buf) require.NoError(t, err) 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) { - // Создаем временную директорию для файлов - tempDir := t.TempDir() + env := setupTestEnv(t, readableMetaViewer()) - // Создаем структуру директорий для тестов - testDataDir := filepath.Join(tempDir, "data", "files") - err := os.MkdirAll(testDataDir, 0755) - require.NoError(t, err) + content := []byte("содержимое записи, которое обязано доехать до диска целиком") + req := createMultipartRequest(t, "sample.m4a", content) - // Временно меняем рабочую директорию для сохранения файлов - 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() - router.ServeHTTP(w, req) + env.router.ServeHTTP(w, req) - // Проверяем результат - assert.Equal(t, http.StatusCreated, w.Code) + require.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 err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) - // Проверяем, что возвращается корректный ответ assert.NotEmpty(t, response.JobID) assert.Equal(t, entity.StateCreated, response.State) - // Проверяем, что файл был сохранен - files, err := filepath.Glob(filepath.Join("data", "files", "*")) - require.NoError(t, err) - assert.Len(t, files, 1) + // Задача действительно заведена, а не только названа в ответе: иначе + // отправитель получит идентификатор записи, которой не будет никогда. + require.Equal(t, 1, countJobs(t, env)) - // Проверяем размер сохраненного файла - fileInfo, err := os.Stat(files[0]) + job, err := env.handler.jobRepo.GetByID(response.JobID) 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) { - 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("запись")) + }, + }, + } - // Создаем запрос без файла - req, err := http.NewRequest("POST", "/api/audio", nil) - require.NoError(t, err) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + env := setupTestEnv(t, readableMetaViewer()) - // Выполняем запрос - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + w := httptest.NewRecorder() + env.router.ServeHTTP(w, tc.req(t)) - // Проверяем результат - assert.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, http.StatusBadRequest, w.Code) - var response map[string]string - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) + var response map[string]string + err := json.Unmarshal(w.Body.Bytes(), &response) + 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) { - // Создаем временную директорию для файлов - tempDir := t.TempDir() + env := setupTestEnv(t, readableMetaViewer()) - // Создаем структуру директорий для тестов - testDataDir := filepath.Join(tempDir, "data", "files") - err := os.MkdirAll(testDataDir, 0755) - require.NoError(t, err) + // Собственного порога по размеру у приёма нет: годность записи судит + // источник метаданных, а не приём. + req := createMultipartRequest(t, "empty.m4a", nil) - // Временно меняем рабочую директорию для сохранения файлов - 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() - router.ServeHTTP(w, req) + env.router.ServeHTTP(w, req) - // Проверяем результат - даже пустой файл должен быть принят - assert.Equal(t, http.StatusCreated, w.Code) + require.Equal(t, http.StatusCreated, w.Code) var response CreateTranscribeJobResponse - err = json.Unmarshal(w.Body.Bytes(), &response) + err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.NotEmpty(t, response.JobID) @@ -257,102 +314,96 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) { func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) { testCases := []struct { name string - filename string + fileName string expectExt string }{ { name: "m4a file", - filename: "test.m4a", + fileName: "test.m4a", expectExt: ".m4a", }, { name: "mp3 file", - filename: "test.mp3", + fileName: "test.mp3", expectExt: ".mp3", }, { name: "wav file", - filename: "test.wav", + fileName: "test.wav", expectExt: ".wav", }, { name: "file without extension", - filename: "test", + fileName: "test", expectExt: ".audio", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Создаем временную директорию для файлов - tempDir := t.TempDir() + env := setupTestEnv(t, readableMetaViewer()) - // Создаем структуру директорий для тестов - testDataDir := filepath.Join(tempDir, "data", "files") - err := os.MkdirAll(testDataDir, 0755) - require.NoError(t, err) + req := createMultipartRequest(t, tc.fileName, []byte("запись")) - // Временно меняем рабочую директорию для сохранения файлов - 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() - router.ServeHTTP(w, req) + env.router.ServeHTTP(w, req) - // Проверяем результат - assert.Equal(t, http.StatusCreated, w.Code) + require.Equal(t, http.StatusCreated, w.Code) - // Проверяем, что файл сохранен с правильным расширением - files, err := filepath.Glob(filepath.Join("data", "files", "*"+tc.expectExt)) - require.NoError(t, err) - assert.Len(t, files, 1) + files := storedFiles(t, env) + require.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) { - router, handler := setupTestRouter(t) + env := setupTestEnv(t, readableMetaViewer()) - // Создаем тестовую запись в базе данных job := &entity.TranscribeJob{ Id: "test-job-id", State: entity.StateCreated, + Source: entity.SourceApi, FileID: nil, IsError: false, CreatedAt: time.Now(), } - err := handler.jobRepo.Create(job) + err := env.handler.jobRepo.Create(job) require.NoError(t, err) - // Создаем запрос req, err := http.NewRequest("GET", "/api/status/test-job-id", nil) require.NoError(t, err) - // Выполняем запрос w := httptest.NewRecorder() - router.ServeHTTP(w, req) + env.router.ServeHTTP(w, req) - // Проверяем результат - assert.Equal(t, http.StatusOK, w.Code) + require.Equal(t, http.StatusOK, w.Code) var response GetTranscribeJobResponse err = json.Unmarshal(w.Body.Bytes(), &response) @@ -363,19 +414,50 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) { 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) { - router, _ := setupTestRouter(t) + env := setupTestEnv(t, readableMetaViewer()) - // Создаем запрос с несуществующим ID req, err := http.NewRequest("GET", "/api/status/non-existent-id", nil) require.NoError(t, err) - // Выполняем запрос w := httptest.NewRecorder() - router.ServeHTTP(w, req) + env.router.ServeHTTP(w, req) - // Проверяем результат - assert.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, http.StatusNotFound, w.Code) var response map[string]string 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"]) } - -type TestTgSender struct{} - -func (s *TestTgSender) Send(msg string, chatId int64, replyMsgId *int) error { - return nil -} diff --git a/openspec/changes/archive/2026-08-11-fix-http-handler-tests/.openspec.yaml b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/.openspec.yaml new file mode 100644 index 0000000..a8821c7 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/archive/2026-08-11-fix-http-handler-tests/design.md b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/design.md new file mode 100644 index 0000000..af58d21 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/design.md @@ -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 остаётся + в коде и без требований. → Смягчение: назван в самой спеке. Требование, + написанное без проверки, было бы предположением, а не нормой. diff --git a/openspec/changes/archive/2026-08-11-fix-http-handler-tests/proposal.md b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/proposal.md new file mode 100644 index 0000000..8f2d0c9 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/proposal.md @@ -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 + изменение не касается. diff --git a/openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md new file mode 100644 index 0000000..8f59c5f --- /dev/null +++ b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md @@ -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`, `..` результат всегда + `` внутри каталога хранения (оракул: прогон `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`. diff --git a/openspec/changes/archive/2026-08-11-fix-http-handler-tests/specs/intake/spec.md b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/specs/intake/spec.md new file mode 100644 index 0000000..d89fde8 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/specs/intake/spec.md @@ -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` и сообщение о ненайденной задаче diff --git a/openspec/changes/archive/2026-08-11-fix-http-handler-tests/tasks.md b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/tasks.md new file mode 100644 index 0000000..1fe44af --- /dev/null +++ b/openspec/changes/archive/2026-08-11-fix-http-handler-tests/tasks.md @@ -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` пуст. diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md new file mode 100644 index 0000000..2135b0c --- /dev/null +++ b/openspec/specs/intake/spec.md @@ -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` и сообщение о ненайденной задаче +