diff --git a/.gitignore b/.gitignore index e4a9cb7..a534a71 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,8 @@ Thumbs.db .env .env.local .env.*.local + +# Sample and test audio files +*.m4a +*.mp3 +*.ogg \ No newline at end of file diff --git a/go.mod b/go.mod index 4ac26fd..2453545 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.4.0 github.com/mattn/go-sqlite3 v1.14.17 github.com/pressly/goose/v3 v3.15.1 + github.com/stretchr/testify v1.10.0 ) require ( @@ -15,6 +16,7 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -28,6 +30,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect diff --git a/go.sum b/go.sum index 9d8df83..eebbaac 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go new file mode 100644 index 0000000..df8b031 --- /dev/null +++ b/internal/controller/http/transcribe_test.go @@ -0,0 +1,374 @@ +package http + +import ( + "bytes" + "database/sql" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "git.vakhrushev.me/av/transcriber/internal/entity" + "git.vakhrushev.me/av/transcriber/internal/repo/sqlite" + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) { + // Создаем временную базу данных в памяти + db, err := sql.Open("sqlite3", ":memory:") + require.NoError(t, err) + + gq := goqu.New("sqlite3", db) + + // Создаем таблицы + createFilesTable := ` + CREATE TABLE files ( + id TEXT PRIMARY KEY, + storage TEXT NOT NULL, + size INTEGER NOT NULL, + created_at DATETIME NOT NULL + );` + + createJobsTable := ` + CREATE TABLE transcribe_jobs ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL, + file_id TEXT, + is_error BOOLEAN NOT NULL DEFAULT 0, + error_text TEXT, + worker TEXT, + acquired_at DATETIME, + created_at DATETIME NOT NULL, + FOREIGN KEY (file_id) REFERENCES files(id) + );` + + _, err = db.Exec(createFilesTable) + require.NoError(t, err) + + _, err = db.Exec(createJobsTable) + require.NoError(t, err) + + return db, gq +} + +func setupTestRouter(t *testing.T) (*gin.Engine, *TranscribeHandler) { + gin.SetMode(gin.TestMode) + + db, gq := setupTestDB(t) + + fileRepo := sqlite.NewFileRepository(db, gq) + jobRepo := sqlite.NewTranscriptJobRepository(db, gq) + + handler := NewTranscribeHandler(jobRepo, fileRepo) + + router := gin.New() + router.MaxMultipartMemory = 32 << 20 // 32 MiB + + api := router.Group("/api") + { + api.POST("/transcribe/audio", handler.CreateTranscribeJob) + api.GET("/transcribe/:id", handler.GetTranscribeJobStatus) + } + + return router, handler +} + +func createMultipartRequest(t *testing.T, audioFilePath string) (*http.Request, string) { + // Открываем тестовый аудио файл + file, err := os.Open(audioFilePath) + require.NoError(t, err) + defer file.Close() + + // Создаем буфер для multipart формы + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Создаем поле для файла + part, err := writer.CreateFormFile("audio", filepath.Base(audioFilePath)) + require.NoError(t, err) + + // Копируем содержимое файла + _, err = io.Copy(part, file) + require.NoError(t, err) + + // Закрываем writer + err = writer.Close() + require.NoError(t, err) + + // Создаем HTTP запрос + req, err := http.NewRequest("POST", "/api/transcribe/audio", &buf) + require.NoError(t, err) + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + return req, writer.FormDataContentType() +} + +func TestCreateTranscribeJob_Success(t *testing.T) { + // Создаем временную директорию для файлов + tempDir := t.TempDir() + + // Создаем структуру директорий для тестов + testDataDir := filepath.Join(tempDir, "data", "files") + err := os.MkdirAll(testDataDir, 0755) + require.NoError(t, err) + + // Временно меняем рабочую директорию для сохранения файлов + 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) + + // Проверяем результат + assert.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) + + // Проверяем, что файл был сохранен + files, err := filepath.Glob(filepath.Join("data", "files", "*")) + require.NoError(t, err) + assert.Len(t, files, 1) + + // Проверяем размер сохраненного файла + fileInfo, err := os.Stat(files[0]) + require.NoError(t, err) + assert.Greater(t, fileInfo.Size(), int64(0)) +} + +func TestCreateTranscribeJob_NoFile(t *testing.T) { + router, _ := setupTestRouter(t) + + // Создаем запрос без файла + req, err := http.NewRequest("POST", "/api/transcribe/audio", nil) + require.NoError(t, err) + + // Выполняем запрос + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Проверяем результат + assert.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"]) +} + +func TestCreateTranscribeJob_EmptyFile(t *testing.T) { + // Создаем временную директорию для файлов + tempDir := t.TempDir() + + // Создаем структуру директорий для тестов + testDataDir := filepath.Join(tempDir, "data", "files") + err := os.MkdirAll(testDataDir, 0755) + require.NoError(t, err) + + // Временно меняем рабочую директорию для сохранения файлов + 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) + + // Проверяем результат - даже пустой файл должен быть принят + assert.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) { + // Создаем временную директорию для файлов + tempDir := t.TempDir() + + // Создаем структуру директорий для тестов + testDataDir := filepath.Join(tempDir, "data", "files") + err := os.MkdirAll(testDataDir, 0755) + require.NoError(t, err) + + // Временно меняем рабочую директорию для сохранения файлов + 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) + + // Проверяем результат + assert.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) + }) + } +} + +func TestGetTranscribeJobStatus_Success(t *testing.T) { + router, handler := setupTestRouter(t) + + // Создаем тестовую запись в базе данных + job := &entity.TranscribeJob{ + Id: "test-job-id", + State: entity.StateCreated, + FileID: nil, + IsError: false, + CreatedAt: time.Now(), + } + + err := handler.jobRepo.Create(job) + require.NoError(t, err) + + // Создаем запрос + req, err := http.NewRequest("GET", "/api/transcribe/test-job-id", nil) + require.NoError(t, err) + + // Выполняем запрос + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Проверяем результат + assert.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_NotFound(t *testing.T) { + router, _ := setupTestRouter(t) + + // Создаем запрос с несуществующим ID + req, err := http.NewRequest("GET", "/api/transcribe/non-existent-id", nil) + require.NoError(t, err) + + // Выполняем запрос + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Проверяем результат + assert.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"]) +}