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"]) }