- проверки больше не зовут ffprobe и не меняют рабочий каталог процесса; добавлены случаи на отказ чтения метаданных и на отсутствие поля audio - заведена спека intake на приём по HTTP, ADR о подставных адаптерах, запись в журнал ревью о проверке, которая не могла упасть - go test снят из объявленных долгов CLAUDE.md, послабление errcheck для _test.go в .golangci.yml убрано
468 lines
15 KiB
Go
468 lines
15 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
|
|
"github.com/gin-gonic/gin"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/pressly/goose/v3"
|
|
"github.com/stretchr/testify/assert"
|
|
"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)
|
|
|
|
err = goose.SetDialect("sqlite3")
|
|
require.NoError(t, err)
|
|
|
|
_, b, _, _ := runtime.Caller(0)
|
|
|
|
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 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)
|
|
|
|
// Журнал проверкам не нужен: судят они по ответу и по базе. А ветка отказа
|
|
// метаданных теперь проходится нарочно, и её ERROR-строки на зелёном
|
|
// прогоне размывали бы признак, по которому отличают новый красный шаг
|
|
// гейта от объявленного долга.
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
trsService := service.NewTranscribeService(
|
|
jobRepo,
|
|
fileRepo,
|
|
metaviewer,
|
|
&stubConverter{},
|
|
&recognizer.MemoryAudioRecognizer{},
|
|
&TestTgSender{},
|
|
storageDir,
|
|
logger,
|
|
)
|
|
|
|
handler := NewTranscribeHandler(jobRepo, trsService)
|
|
|
|
router := gin.New()
|
|
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
|
|
|
api := router.Group("/api")
|
|
{
|
|
api.POST("/audio", handler.CreateTranscribeJob)
|
|
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
|
}
|
|
|
|
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir}
|
|
}
|
|
|
|
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
|
// для этого не нужно: имя проверяет выбор расширения, содержимое — сохранение.
|
|
func createMultipartRequest(t *testing.T, fileName string, content []byte) *http.Request {
|
|
return createMultipartRequestWithField(t, "audio", fileName, content)
|
|
}
|
|
|
|
// 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(field, fileName)
|
|
require.NoError(t, err)
|
|
|
|
_, err = part.Write(content)
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Close()
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest("POST", "/api/audio", &buf)
|
|
require.NoError(t, err)
|
|
|
|
req.Header.Set("Content-Type", 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) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
content := []byte("содержимое записи, которое обязано доехать до диска целиком")
|
|
req := createMultipartRequest(t, "sample.m4a", content)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.router.ServeHTTP(w, req)
|
|
|
|
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)
|
|
|
|
// Задача действительно заведена, а не только названа в ответе: иначе
|
|
// отправитель получит идентификатор записи, которой не будет никогда.
|
|
require.Equal(t, 1, countJobs(t, env))
|
|
|
|
job, err := env.handler.jobRepo.GetByID(response.JobID)
|
|
require.NoError(t, err)
|
|
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) {
|
|
// Две ветки одного сценария: тела нет вовсе и форма есть, а поля в ней нет.
|
|
// Вторая — та, что описана требованием; первая ходит тем же путём.
|
|
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 {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
w := httptest.NewRecorder()
|
|
env.router.ServeHTTP(w, tc.req(t))
|
|
|
|
require.Equal(t, http.StatusBadRequest, w.Code)
|
|
|
|
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.Empty(t, storedFiles(t, env))
|
|
assert.Equal(t, 0, countJobs(t, env))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
// Собственного порога по размеру у приёма нет: годность записи судит
|
|
// источник метаданных, а не приём.
|
|
req := createMultipartRequest(t, "empty.m4a", nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
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)
|
|
}
|
|
|
|
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
fileName string
|
|
expectExt string
|
|
}{
|
|
{
|
|
name: "m4a file",
|
|
fileName: "test.m4a",
|
|
expectExt: ".m4a",
|
|
},
|
|
{
|
|
name: "mp3 file",
|
|
fileName: "test.mp3",
|
|
expectExt: ".mp3",
|
|
},
|
|
{
|
|
name: "wav file",
|
|
fileName: "test.wav",
|
|
expectExt: ".wav",
|
|
},
|
|
{
|
|
name: "file without extension",
|
|
fileName: "test",
|
|
expectExt: ".audio",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
|
|
|
w := httptest.NewRecorder()
|
|
env.router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
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) {
|
|
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 := 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()
|
|
env.router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusOK, w.Code)
|
|
|
|
var response GetTranscribeJobResponse
|
|
err = json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "test-job-id", response.JobID)
|
|
assert.Equal(t, entity.StateCreated, response.State)
|
|
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) {
|
|
env := setupTestEnv(t, readableMetaViewer())
|
|
|
|
req, err := http.NewRequest("GET", "/api/status/non-existent-id", nil)
|
|
require.NoError(t, err)
|
|
|
|
w := httptest.NewRecorder()
|
|
env.router.ServeHTTP(w, req)
|
|
|
|
require.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
var response map[string]string
|
|
err = json.Unmarshal(w.Body.Bytes(), &response)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "Job not found", response["error"])
|
|
}
|