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

- проверки больше не зовут ffprobe и не меняют рабочий каталог процесса;
  добавлены случаи на отказ чтения метаданных и на отсутствие поля audio
- заведена спека intake на приём по HTTP, ADR о подставных адаптерах,
  запись в журнал ревью о проверке, которая не могла упасть
- go test снят из объявленных долгов CLAUDE.md, послабление errcheck
  для _test.go в .golangci.yml убрано
This commit is contained in:
av
2026-08-11 08:35:11 +03:00
parent bb973b0a68
commit 6c04c801c9
14 changed files with 1175 additions and 205 deletions
+256 -180
View File
@@ -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
}