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