Files
transcriber/internal/controller/http/transcribe_test.go
T
av bd6001cd8d имя файла отправителя убрано из журнала приёма
- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
2026-08-11 16:37:58 +03:00

675 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package http
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"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/prometheus/client_golang/prometheus"
sloggin "github.com/samber/slog-gin"
"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
journal *journalBuffer
}
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
// что пишущих в него потоков три: логгер сервиса, стандартный `log` транспорта
// и middleware запроса.
type journalBuffer struct {
mu sync.Mutex
text strings.Builder
}
func (b *journalBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.Write(p)
}
// String отдаёт весь перехваченный текст. Проверки ищут в нём значение, а не имя
// поля: имя, вернувшееся под другим ключом, поиск по ключу не разбудил бы.
func (b *journalBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.text.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-строки ветки
// отказа по-прежнему не попадают на экран и не размывают признак, по
// которому отличают новый красный шаг гейта от объявленного долга.
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
// Второй писатель журнала приёма — HTTP-транспорт: он пишет через стандартный
// `log` (расхождение записано в docs/conventions/logging.md). В бою `main.go`
// зовёт `slog.SetDefault`, и такая запись садится в `msg` строки `slog`;
// повторяем это здесь, чтобы оракул видел ту же цепочку, что и прод, а не
// свою. Без перехвата оракул был бы уже требования, которое накрывает все
// журнальные записи приёма.
//
// Подмена процессная, а не своя у случая: `t.Parallel()` в этом файле
// запрещён. При параллельных случаях вывод указывал бы на буфер соседа, и
// проверка запрета прошла бы, ничего не прочитав.
prevDefault := slog.Default()
slog.SetDefault(logger)
t.Cleanup(func() { slog.SetDefault(prevDefault) })
trsService := service.NewTranscribeService(
jobRepo,
fileRepo,
metaviewer,
&stubConverter{},
&recognizer.MemoryAudioRecognizer{},
&TestTgSender{},
storageDir,
logger,
)
handler := NewTranscribeHandler(jobRepo, trsService)
// Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три
// пишущих в журнал потока, и middleware — третий. Без него требование «ни
// одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит.
router := gin.New()
router.Use(sloggin.New(logger))
router.Use(gin.Recovery())
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, journal: journal}
}
// 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))
}
// senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и
// заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому
// находка означает утечку, а не совпадение. Ищется она **значением**, а не
// именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу
// пропустил бы.
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
// Тексты, по которым проверки находят журнальные строки. Оба — записанный долг
// `docs/conventions/logging.md`: `msg` обязан стать короткой категорией, а
// транспорту не положено логировать вовсе. Когда долг закроют, правка будет
// здесь и одна, а смысл утверждений менять не придётся.
const (
msgIntake = "Creating transcribe job"
msgTransportErr = "Err:"
msgMiddleware = "Incoming request"
)
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Метка стоит в основе имени, а расширение обычное: расширение запретом не
// накрыто и в журнале остаётся законно.
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
journal := env.journal.String()
// Сперва — что поток middleware вообще перехвачен. Он третий писатель
// журнала приёма, и без этого утверждения снятие его из тестового роутера
// сузило бы оракул молча.
require.Contains(t, journal, msgMiddleware,
"строка middleware о запросе попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker,
"имя, данное отправителем, не пишется в журнал: инвариант приватности")
}
func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
// Отказ — тот путь, где имя приехало бы в журнал текстом ошибки: приём
// назван конвенцией логирующей границей, и цепочка `%w` осядет полем error.
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
journal := env.journal.String()
// Сперва — что второй поток журнала вообще перехвачен. Без этого
// утверждения снятие `slog.SetDefault` из окружения оставило бы проверку
// зелёной, а оракул критического инварианта молча сузился бы вдвое.
require.Contains(t, journal, msgTransportErr,
"строка транспорта, идущая мимо slog, попадает в перехваченный журнал")
assert.NotContains(t, journal, senderNameMarker,
"имя отправителя не пишется в журнал и на пути отказа")
}
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
content := []byte("содержимое записи")
req := createMultipartRequest(t, "sample.mp3", content)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
job, err := env.handler.jobRepo.GetByID(response.JobID)
require.NoError(t, err)
require.NotNil(t, job.FileID)
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
// сохранённой при пустом журнале.
journal := env.journal.String()
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
// конвенции — JSON. Утверждение держится на значении и переживёт замену.
assert.Regexp(t, fmt.Sprintf(`size["=:\s]+%d`, len(content)), journal,
"размер принятой записи в байтах в журнале остаётся")
// Запись о приёме не сменила адресата: на DEBUG её в боевой настройке не
// будет вовсе, и разбор постфактум опереться будет не на что. Уровень
// ищется в строке самого приёма — соседние строки тоже идут на INFO, и
// поиск по всему журналу не упал бы от понижения этой.
assert.Regexp(t, `(?m)^.*level["=:\s]+INFO.*`+regexp.QuoteMeta(msgIntake)+`.*$`, journal,
"строка приёма остаётся на уровне INFO")
// И она ровно одна: вторая строка приёма означала бы второй путь заведения
// задачи мимо общей точки, то есть место, куда запрет не доехал.
assert.Equal(t, 1, strings.Count(journal, msgIntake),
"на принятую запись приходится одна журнальная строка приёма")
}
// metricLabelValues собирает значения меток названного семейства метрик из
// общего реестра процесса. Проверка судит реестр, а не функцию приведения:
// приведение, снятое в точке употребления, функцию не ломает, а хвост имени
// уходит на страницу метрик, которая отдаётся без проверки отправителя.
func metricLabelValues(t *testing.T, family string) []string {
families, err := prometheus.DefaultGatherer.Gather()
require.NoError(t, err)
var values []string
for _, mf := range families {
if mf.GetName() != family {
continue
}
for _, m := range mf.GetMetric() {
for _, label := range m.GetLabel() {
values = append(values, label.GetValue())
}
}
}
return values
}
func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
// Хвост после последней точки — это тоже кусок имени, данного отправителем.
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
values := metricLabelValues(t, "transcriber_input_file_size_bytes")
require.NotEmpty(t, values, "метрика размера принятой записи заполняется приёмом")
assert.NotContains(t, values, "."+senderNameMarker,
"метка метрики не несёт хвоста имени, данного отправителем")
assert.NotContains(t, values, senderNameMarker,
"метка метрики не несёт хвоста имени и без ведущей точки")
assert.Contains(t, values, "other",
"незнакомое расширение приведено к общему значению")
// А на диске расширение остаётся пришедшим: раскладка каталога записей
// объявлена необратимой, и приведение сюда не распространяется.
files := storedFiles(t, env)
require.Len(t, files, 1)
assert.Equal(t, "."+senderNameMarker, filepath.Ext(files[0]))
}
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"])
}