хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
@@ -1,22 +1,30 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TranscribeHandler struct {
|
||||
jobRepo contract.TranscriptJobRepository
|
||||
trsService *service.TranscribeService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService) *TranscribeHandler {
|
||||
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService}
|
||||
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
|
||||
}
|
||||
|
||||
type CreateTranscribeJobResponse struct {
|
||||
@@ -31,74 +39,56 @@ type GetTranscribeJobResponse struct {
|
||||
TranscriptionText *string `json:"transcription_text,omitempty"`
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) CreateTranscribeJob(c *gin.Context) {
|
||||
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
|
||||
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
|
||||
// сохранены — публичный контракт API объявлен необратимым.
|
||||
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
api := r.Group("/api")
|
||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
|
||||
// поэтому предел тела равен потолку самой записи.
|
||||
api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
||||
api.GET("/status/{id}", h.GetTranscribeJobStatus)
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
||||
// Получаем файл из формы
|
||||
file, header, err := c.Request.FormFile("audio")
|
||||
file, header, err := e.Request.FormFile("audio")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No audio file provided"})
|
||||
return
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"})
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
h.logger.Error("Failed to close uploaded file", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
job, err := h.trsService.CreateJobFromApi(file, header.Filename)
|
||||
if err != nil {
|
||||
log.Printf("Err: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create transcibe job"})
|
||||
return
|
||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"})
|
||||
}
|
||||
|
||||
// Возвращаем успешный ответ
|
||||
response := CreateTranscribeJobResponse{
|
||||
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
|
||||
JobID: job.Id,
|
||||
State: job.State,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) GetTranscribeJobStatus(c *gin.Context) {
|
||||
jobID := c.Param("id")
|
||||
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
||||
jobID := e.Request.PathValue("id")
|
||||
|
||||
job, err := h.jobRepo.GetByID(jobID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
||||
return
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetTranscribeJobResponse{
|
||||
return e.JSON(http.StatusOK, GetTranscribeJobResponse{
|
||||
JobID: job.Id,
|
||||
State: job.State,
|
||||
CreatedAt: job.CreatedAt,
|
||||
TranscriptionText: job.TranscriptionText,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) RunConversionJob(c *gin.Context) {
|
||||
err := h.trsService.FindAndRunConversionJob()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) RunTranscribeJob(c *gin.Context) {
|
||||
err := h.trsService.FindAndRunTranscribeJob()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) RunRecognitionCheckJob(c *gin.Context) {
|
||||
err := h.trsService.FindAndRunTranscribeCheckJob()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,30 +9,22 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"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. Проверки судят приём — что
|
||||
@@ -72,21 +63,19 @@ func readableMetaViewer() *stubMetaViewer {
|
||||
return &stubMetaViewer{seconds: 42}
|
||||
}
|
||||
|
||||
// testEnv — собранное окружение одной проверки. Каталог хранения свой у
|
||||
// каждой: рабочий каталог процесса проверки не трогают.
|
||||
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
|
||||
// рабочий каталог процесса проверки не трогают.
|
||||
type testEnv struct {
|
||||
router *gin.Engine
|
||||
handler *TranscribeHandler
|
||||
db *sql.DB
|
||||
storageDir string
|
||||
journal *journalBuffer
|
||||
mux http.Handler
|
||||
handler *TranscribeHandler
|
||||
app core.App
|
||||
journal *journalBuffer
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
|
||||
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
|
||||
// что пишущих в него потоков три: логгер сервиса, стандартный `log` транспорта
|
||||
// и middleware запроса.
|
||||
// что пишущих в него потоков два: логгер сервиса и логгер обработчика.
|
||||
type journalBuffer struct {
|
||||
mu sync.Mutex
|
||||
text strings.Builder
|
||||
@@ -106,44 +95,29 @@ func (b *journalBuffer) String() string {
|
||||
return b.text.String()
|
||||
}
|
||||
|
||||
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
||||
// ровно тем же путём, каким это делает сервис при старте.
|
||||
func newTestStorage(t *testing.T) core.App {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Каждому новому соединению с `: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
|
||||
return app
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
gin.SetMode(gin.TestMode)
|
||||
app := newTestStorage(t)
|
||||
|
||||
db, gq := setupTestDB(t)
|
||||
pbrepo.BindPanelRules(app)
|
||||
|
||||
storageDir := filepath.Join(t.TempDir(), "files")
|
||||
require.NoError(t, os.MkdirAll(storageDir, 0o755))
|
||||
|
||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||
fileRepo := pbrepo.NewFileRepository(app)
|
||||
jobRepo := pbrepo.NewTranscriptJobRepository(app)
|
||||
|
||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||
@@ -152,20 +126,6 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
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,
|
||||
@@ -173,27 +133,21 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
&stubConverter{},
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
&TestTgSender{},
|
||||
storageDir,
|
||||
logger,
|
||||
)
|
||||
|
||||
handler := NewTranscribeHandler(jobRepo, trsService)
|
||||
handler := NewTranscribeHandler(jobRepo, trsService, logger)
|
||||
|
||||
// Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три
|
||||
// пишущих в журнал потока, и middleware — третий. Без него требование «ни
|
||||
// одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит.
|
||||
router := gin.New()
|
||||
router.Use(sloggin.New(logger))
|
||||
router.Use(gin.Recovery())
|
||||
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
||||
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
|
||||
// обработчик, и проверка судит ту же цепочку, что и прод.
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
handler.Register(r)
|
||||
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.POST("/audio", handler.CreateTranscribeJob)
|
||||
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
||||
}
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir, journal: journal}
|
||||
return &testEnv{mux: mux, handler: handler, app: app, journal: journal}
|
||||
}
|
||||
|
||||
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
||||
@@ -217,35 +171,67 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
|
||||
err = writer.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("POST", "/api/audio", &buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/audio", &buf)
|
||||
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, "*"))
|
||||
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
||||
func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
return files
|
||||
|
||||
var names []string
|
||||
for _, record := range records {
|
||||
names = append(names, record.GetStringSlice("file")...)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// countFiles считает записи о файлах.
|
||||
func countFiles(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(pbrepo.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// countJobs считает заведённые задачи расшифровки.
|
||||
func countJobs(t *testing.T, env *testEnv) int {
|
||||
var count int
|
||||
err := env.db.QueryRow("SELECT COUNT(*) FROM transcribe_jobs").Scan(&count)
|
||||
records, err := env.app.FindAllRecords(pbrepo.JobsCollection)
|
||||
require.NoError(t, err)
|
||||
return count
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
||||
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
||||
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
|
||||
t.Helper()
|
||||
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
return name
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
file, err := repo.CreateLocal("sample.mp3", work)
|
||||
require.NoError(t, err)
|
||||
|
||||
job := &entity.TranscribeJob{State: entity.StateCreated, Source: entity.SourceApi, FileID: &file.Id}
|
||||
require.NoError(t, env.handler.jobRepo.Create(job))
|
||||
return job
|
||||
}
|
||||
|
||||
// storedContent читает содержимое файла из хранилища.
|
||||
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
reader, err := repo.Open(fileID)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = buf.ReadFrom(reader)
|
||||
require.NoError(t, err)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
@@ -255,7 +241,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample.m4a", content)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -285,17 +271,9 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
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))
|
||||
// Содержимое лежит в хранилище одним файлом и целиком.
|
||||
require.Equal(t, 1, countFiles(t, env))
|
||||
assert.Equal(t, content, storedContent(t, env, *job.FileID))
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
@@ -308,9 +286,9 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
{
|
||||
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
|
||||
// Запрос строится так, как его видит сервер: у пришедшего по
|
||||
// проводу тело не бывает пустым указателем.
|
||||
return httptest.NewRequest("POST", "/api/audio", http.NoBody)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -326,7 +304,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, tc.req(t))
|
||||
env.mux.ServeHTTP(w, tc.req(t))
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
@@ -335,7 +313,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "No audio file provided", response["error"])
|
||||
assert.Empty(t, storedFiles(t, env))
|
||||
assert.Equal(t, 0, countFiles(t, env))
|
||||
assert.Equal(t, 0, countJobs(t, env))
|
||||
})
|
||||
}
|
||||
@@ -349,7 +327,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
||||
req := createMultipartRequest(t, "empty.m4a", nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -396,28 +374,52 @@ func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
||||
req := createMultipartRequest(t, tc.fileName, []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
files := storedFiles(t, env)
|
||||
require.Len(t, files, 1)
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
// Имя отправителя в хранилище не попадает: имя файла — свой
|
||||
// идентификатор, от отправителя взято только расширение.
|
||||
assert.Equal(t, tc.expectExt, filepath.Ext(files[0]))
|
||||
assert.NotContains(t, filepath.Base(files[0]), tc.fileName)
|
||||
// идентификатор, от отправителя взято только расширение. Суффикс
|
||||
// дописывает само хранилище, поэтому сверяем хвост, а не Ext.
|
||||
assert.True(t, strings.HasSuffix(names[0], tc.expectExt),
|
||||
"имя в хранилище %q оканчивается на %q", names[0], tc.expectExt)
|
||||
assert.NotContains(t, names[0], strings.TrimSuffix(tc.fileName, tc.expectExt))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Имя, данное отправителем, в хранилище не попадает целиком — умолчание
|
||||
// библиотеки, строящее имя файла из него, не применяется. Проверка отдельная от
|
||||
// перебора расширений: там сверяется хвост, здесь — что основы имени нет.
|
||||
func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
assert.NotContains(t, names[0], "секретное-слово",
|
||||
"имя, данное отправителем, в хранилище не попадает")
|
||||
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
|
||||
"расширение при этом сохраняется: %q", names[0])
|
||||
}
|
||||
|
||||
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)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
@@ -439,14 +441,14 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||
// пропустил бы.
|
||||
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
|
||||
|
||||
// Тексты, по которым проверки находят журнальные строки. Оба — записанный долг
|
||||
// `docs/conventions/logging.md`: `msg` обязан стать короткой категорией, а
|
||||
// транспорту не положено логировать вовсе. Когда долг закроют, правка будет
|
||||
// здесь и одна, а смысл утверждений менять не придётся.
|
||||
// Тексты, по которым проверки находят журнальные строки. Первый — записанный
|
||||
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
|
||||
// Когда долг закроют, правка будет здесь и одна.
|
||||
const (
|
||||
msgIntake = "Creating transcribe job"
|
||||
msgTransportErr = "Err:"
|
||||
msgMiddleware = "Incoming request"
|
||||
msgIntake = "Creating transcribe job"
|
||||
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
|
||||
// логировать ошибку один раз, и повторная запись транспорта снята.
|
||||
msgIntakeErr = "Failed to get file info"
|
||||
)
|
||||
|
||||
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
||||
@@ -457,17 +459,16 @@ func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
|
||||
// Сперва — что поток middleware вообще перехвачен. Он третий писатель
|
||||
// журнала приёма, и без этого утверждения снятие его из тестового роутера
|
||||
// сузило бы оракул молча.
|
||||
require.Contains(t, journal, msgMiddleware,
|
||||
"строка middleware о запросе попадает в перехваченный журнал")
|
||||
// Сперва — что журнал приёма вообще перехвачен. Без этого утверждения
|
||||
// пустой буфер сделал бы проверку запрета зелёной, ничего не прочитав.
|
||||
require.Contains(t, journal, msgIntake,
|
||||
"строка приёма попадает в перехваченный журнал")
|
||||
|
||||
assert.NotContains(t, journal, senderNameMarker,
|
||||
"имя, данное отправителем, не пишется в журнал: инвариант приватности")
|
||||
@@ -481,22 +482,45 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
|
||||
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
|
||||
// Сперва — что второй поток журнала вообще перехвачен. Без этого
|
||||
// утверждения снятие `slog.SetDefault` из окружения оставило бы проверку
|
||||
// зелёной, а оракул критического инварианта молча сузился бы вдвое.
|
||||
require.Contains(t, journal, msgTransportErr,
|
||||
"строка транспорта, идущая мимо slog, попадает в перехваченный журнал")
|
||||
// Сперва — что журнал ветки отказа вообще перехвачен: обработчик пишет свою
|
||||
// строку, и без неё оракул молча сузился бы вдвое.
|
||||
require.Contains(t, journal, msgIntakeErr,
|
||||
"строка приёма об отказе попадает в перехваченный журнал")
|
||||
|
||||
assert.NotContains(t, journal, senderNameMarker,
|
||||
"имя отправителя не пишется в журнал и на пути отказа")
|
||||
}
|
||||
|
||||
// Имя, под которым файл лёг в хранилище, — это последняя часть ссылки
|
||||
// `/api/files/...`, по которой запись скачивают. Попав в журнал, строка стала бы
|
||||
// бессрочным ключом к чужому аудио, поэтому в журнал идёт имя, заданное
|
||||
// сервисом, а суффикс хранилища — нет.
|
||||
func TestCreateTranscribeJob_StorageFileNameNotLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
journal := env.journal.String()
|
||||
require.Contains(t, journal, msgIntake, "журнал приёма перехвачен")
|
||||
|
||||
assert.NotContains(t, journal, names[0],
|
||||
"имени файла в хранилище в журнале нет: по нему собирается ссылка на скачивание")
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -504,7 +528,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample.mp3", content)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -569,7 +593,7 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
||||
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
@@ -582,41 +606,30 @@ func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
||||
assert.Contains(t, values, "other",
|
||||
"незнакомое расширение приведено к общему значению")
|
||||
|
||||
// А на диске расширение остаётся пришедшим: раскладка каталога записей
|
||||
// объявлена необратимой, и приведение сюда не распространяется.
|
||||
files := storedFiles(t, env)
|
||||
require.Len(t, files, 1)
|
||||
assert.Equal(t, "."+senderNameMarker, filepath.Ext(files[0]))
|
||||
// А в хранилище расширение остаётся пришедшим: приведение сюда не
|
||||
// распространяется.
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
assert.True(t, strings.HasSuffix(names[0], "."+senderNameMarker),
|
||||
"имя в хранилище сохраняет пришедшее расширение: %q", names[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(),
|
||||
}
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
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)
|
||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, "test-job-id", response.JobID)
|
||||
assert.Equal(t, job.Id, response.JobID)
|
||||
assert.Equal(t, entity.StateCreated, response.State)
|
||||
assert.NotZero(t, response.CreatedAt)
|
||||
}
|
||||
@@ -624,21 +637,12 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||
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(),
|
||||
}
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
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)
|
||||
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
@@ -646,8 +650,7 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
||||
// читается клиентом как «расшифровка пуста», и разобранная структура
|
||||
// эти два случая не различает.
|
||||
var raw map[string]json.RawMessage
|
||||
err = json.Unmarshal(w.Body.Bytes(), &raw)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
|
||||
assert.Contains(t, raw, "job_id")
|
||||
assert.Contains(t, raw, "status")
|
||||
@@ -658,17 +661,15 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
|
||||
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)
|
||||
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
env.mux.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)
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, "Job not found", response["error"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user