- расширение приводится к перечню известных форматов прежде метки метрики: страница метрик открыта, и хвост имени уезжал на неё дословно - проверки приёма перехватывают все три потока журнала и читают реестр метрик, каждая падает при снятии того, что сторожит
675 lines
27 KiB
Go
675 lines
27 KiB
Go
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"])
|
||
}
|