внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
@@ -38,7 +38,7 @@ func TestApiRequiresSession(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, files)
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, jobs)
|
||||
})
|
||||
@@ -64,7 +64,7 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobs, 1)
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestLoginCreatesAccountAndSession(t *testing.T) {
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
NewTranscribeHandler(pbrepo.NewTranscriptJobRepository(env.app), nil, nil).Register(r)
|
||||
NewTranscribeHandler(pbrepo.NewAudioRecordRepository(env.app), pbrepo.NewTextRepository(env.app), nil, nil).Register(r)
|
||||
checkMux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
checkMux.ServeHTTP(checkResponse, check)
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
@@ -74,7 +77,7 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
|
||||
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
|
||||
|
||||
// Ни состояния, ни текста расшифровки в теле нет.
|
||||
assert.NotContains(t, foreign.Body.String(), entity.StateCreated)
|
||||
assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
|
||||
assert.NotContains(t, foreign.Body.String(), "transcription_text")
|
||||
}
|
||||
|
||||
@@ -88,15 +91,16 @@ func TestGetTranscribeJobStatus_OwnerlessJobLooksMissing(t *testing.T) {
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
// Файл записи из Telegram владельца тоже не имеет.
|
||||
file, err := fileRepo.CreateLocal("voice.ogg", work, "")
|
||||
file, err := fileRepo.Create("voice.ogg", work, contract.FileMeta{Format: "ogg"}, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceTelegram,
|
||||
FileID: &file.Id,
|
||||
job := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceTelegram,
|
||||
OriginalFileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.jobRepo.Create(job))
|
||||
require.NoError(t, env.handler.recordRepo.Create(job))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
|
||||
@@ -116,11 +120,11 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||
var response CreateTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
record, err := env.app.FindRecordById("transcribe_jobs", response.JobID)
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
|
||||
|
||||
fileRecord, err := env.app.FindRecordById("files", record.GetString("file"))
|
||||
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
|
||||
}
|
||||
@@ -143,7 +147,7 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
||||
var response CreateTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
record, err := env.app.FindRecordById("transcribe_jobs", response.JobID)
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"))
|
||||
}
|
||||
@@ -187,7 +191,7 @@ func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||
job := jobWithFile(t, env)
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
|
||||
record, err := env.app.FindRecordById("files", *job.FileID)
|
||||
record, err := env.app.FindRecordById("files", *job.OriginalFileID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, env.account.Id, record.GetString("owner"))
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Ответ об одной записи — то, ради чего эндпойнт и существует; ниже судятся его
|
||||
// ветки: готовый текст, остановленная запись и отказ хранилища на чтении текста.
|
||||
|
||||
// statusOf спрашивает состояние записи от имени её владельца.
|
||||
func statusOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/api/status/"+recordID, http.NoBody))
|
||||
return w
|
||||
}
|
||||
|
||||
// Готовая расшифровка доезжает до отправителя полем `transcription_text`, и
|
||||
// уходит в него **сырая** расшифровка: видов текста больше одного, и отдача
|
||||
// «последнего записанного» сделала бы ответ функцией порядка записи.
|
||||
func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||
require.NoError(t, err)
|
||||
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
record.LiteraryTextID = &literary.Id
|
||||
record.MoveToState(entity.StateDone)
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := statusOf(t, env, record.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, entity.StateDone, response.State)
|
||||
require.NotNil(t, response.TranscriptionText, "готовый текст доехал до отправителя")
|
||||
assert.Equal(t, "сырая расшифровка", *response.TranscriptionText)
|
||||
assert.NotContains(t, w.Body.String(), "вычитанный текст",
|
||||
"вычитанный текст этим полем не подменяется: значение поля не должно меняться от того, успел ли необязательный шаг")
|
||||
}
|
||||
|
||||
// Остановленная запись отдаёт рубеж, на котором встала, и признак остановки
|
||||
// отдельным полем: отказ перестал быть состоянием, и без признака такая запись
|
||||
// выглядела бы обычной, стоящей на своём рубеже.
|
||||
func TestGetTranscribeJobStatus_HaltedIsVisible(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
record.MoveToState(entity.StateNormalized)
|
||||
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := statusOf(t, env, record.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, entity.StateNormalized, response.State, "рубеж тот, на котором запись встала")
|
||||
assert.True(t, response.Halted, "признак остановки виден отправителю")
|
||||
assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
|
||||
"машинный текст отказа принадлежит журналу владельца, а не ответу отправителю")
|
||||
}
|
||||
|
||||
// Пока запись не дошла до текста, поля нет вовсе: пустая строка на его месте
|
||||
// читается как «расшифровка пуста».
|
||||
func TestGetTranscribeJobStatus_RunningRecordHasNoHaltedFlag(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
w := statusOf(t, env, record.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, entity.StateUploaded, response.State)
|
||||
assert.False(t, response.Halted, "запись в работе остановленной не значится")
|
||||
assert.Nil(t, response.TranscriptionText)
|
||||
}
|
||||
|
||||
// failingTextRepo отказывает на чтении текста — так выглядит недоступное
|
||||
// хранилище. Битую ссылку схема завести не даёт (связь проверяется при
|
||||
// сохранении), и это её защита, а не пробел: остаётся отказ самого чтения.
|
||||
type failingTextRepo struct{}
|
||||
|
||||
func (r *failingTextRepo) Put(string, string, string) (*entity.Text, error) {
|
||||
return nil, errors.New("не зовётся этой проверкой")
|
||||
}
|
||||
|
||||
func (r *failingTextRepo) GetByID(string) (*entity.Text, error) {
|
||||
return nil, errors.New("хранилище недоступно")
|
||||
}
|
||||
|
||||
// Отказ чтения текста — это отказ хранилища, а не «записи нет». Отправителю он
|
||||
// приходит своим кодом, и владелец сервиса узнаёт об аварии из журнала — иначе
|
||||
// она читалась бы отправителю как «вашей записи не существует», а владельцем не
|
||||
// замечалась бы вовсе.
|
||||
func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
|
||||
// цепочка та же, что и в проде.
|
||||
journal := &journalBuffer{}
|
||||
handler := NewTranscribeHandler(
|
||||
env.handler.recordRepo,
|
||||
&failingTextRepo{},
|
||||
env.handler.trsService,
|
||||
slog.New(slog.NewTextHandler(journal, nil)),
|
||||
)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
handler.Register(r)
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code,
|
||||
"отказ хранилища не выдаётся за отсутствие записи")
|
||||
assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят")
|
||||
assert.Contains(t, journal.String(), "Failed to read transcript",
|
||||
"владелец сервиса узнаёт об аварии из журнала")
|
||||
}
|
||||
@@ -18,16 +18,22 @@ import (
|
||||
)
|
||||
|
||||
type TranscribeHandler struct {
|
||||
jobRepo contract.TranscriptJobRepository
|
||||
recordRepo contract.AudioRecordRepository
|
||||
textRepo contract.TextRepository
|
||||
trsService *service.TranscribeService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewTranscribeHandler(jobRepo contract.TranscriptJobRepository, trsService *service.TranscribeService, logger *slog.Logger) *TranscribeHandler {
|
||||
func NewTranscribeHandler(
|
||||
recordRepo contract.AudioRecordRepository,
|
||||
textRepo contract.TextRepository,
|
||||
trsService *service.TranscribeService,
|
||||
logger *slog.Logger,
|
||||
) *TranscribeHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &TranscribeHandler{jobRepo: jobRepo, trsService: trsService, logger: logger}
|
||||
return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
|
||||
}
|
||||
|
||||
type CreateTranscribeJobResponse struct {
|
||||
@@ -35,16 +41,27 @@ type CreateTranscribeJobResponse struct {
|
||||
State string `json:"status"`
|
||||
}
|
||||
|
||||
// GetTranscribeJobResponse — ответ об одной записи.
|
||||
//
|
||||
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
|
||||
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
|
||||
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
|
||||
// это объявленная ломка.
|
||||
//
|
||||
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
|
||||
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
|
||||
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
|
||||
type GetTranscribeJobResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
State string `json:"status"`
|
||||
Halted bool `json:"halted"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TranscriptionText *string `json:"transcription_text,omitempty"`
|
||||
}
|
||||
|
||||
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
|
||||
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
|
||||
// сохранены — публичный контракт API объявлен необратимым.
|
||||
// сохранены — публичный контракт HTTP API объявлен необратимым.
|
||||
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
api := r.Group("/api")
|
||||
|
||||
@@ -80,18 +97,18 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
||||
}
|
||||
}()
|
||||
|
||||
// Запись доехала целиком, поэтому задача заводится независимо от того,
|
||||
// дождётся ли отправитель ответа: на контексте запроса приём терял бы
|
||||
// полностью загруженную запись от одного обрыва соединения, а забрать
|
||||
// результат он может и позже — по `GET /status/{id}`. Значения контекста
|
||||
// (журнал запроса, сессия) при этом сохраняются, теряется только отмена.
|
||||
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||||
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||||
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||||
// может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
|
||||
// сессия) при этом сохраняются, теряется только отмена.
|
||||
ctx := context.WithoutCancel(e.Request.Context())
|
||||
|
||||
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
||||
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
||||
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
|
||||
// уже есть и принадлежит коллекции пользователей.
|
||||
job, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
||||
if err != nil {
|
||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
|
||||
@@ -100,37 +117,53 @@ func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
|
||||
|
||||
// Возвращаем успешный ответ
|
||||
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
|
||||
JobID: job.Id,
|
||||
State: job.State,
|
||||
JobID: record.Id,
|
||||
State: record.State,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
|
||||
jobID := e.Request.PathValue("id")
|
||||
recordID := e.Request.PathValue("id")
|
||||
|
||||
// Чужая задача, задача без владельца и несуществующая отвечают одним и тем
|
||||
// Чужая запись, запись без владельца и несуществующая отвечают одним и тем
|
||||
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
|
||||
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
|
||||
// список заведённых задач.
|
||||
job, err := h.jobRepo.GetByID(jobID, e.Auth.Id)
|
||||
// список заведённых записей.
|
||||
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
||||
if err != nil {
|
||||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||||
// «Задачи нет» и «задача чужая» — штатная работа разграничения, о ней
|
||||
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
||||
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
||||
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
||||
// аварии не узнаёт ниоткуда. Журнал читает владелец, а не тот, кто
|
||||
// перебирает, поэтому различать их здесь можно.
|
||||
// аварии не узнаёт ниоткуда.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
h.logger.Error("Failed to read transcribe job", "error", err, "job_id", jobID)
|
||||
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
||||
}
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, GetTranscribeJobResponse{
|
||||
JobID: job.Id,
|
||||
State: job.State,
|
||||
CreatedAt: job.CreatedAt,
|
||||
TranscriptionText: job.TranscriptionText,
|
||||
})
|
||||
response := GetTranscribeJobResponse{
|
||||
JobID: record.Id,
|
||||
State: record.State,
|
||||
Halted: record.IsHalted(),
|
||||
CreatedAt: record.CreatedAt,
|
||||
}
|
||||
|
||||
// Вид текста называется **явно**: видов у записи больше одного, и отдача
|
||||
// «последнего записанного» сделала бы ответ функцией порядка записи, а не
|
||||
// состояния записи. В это поле уходит сырая расшифровка, и только она.
|
||||
if record.TranscriptTextID != nil {
|
||||
text, err := h.textRepo.GetByID(*record.TranscriptTextID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID)
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"})
|
||||
}
|
||||
if text.Contents != "" {
|
||||
contents := text.Contents
|
||||
response.TranscriptionText = &contents
|
||||
}
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
@@ -167,8 +169,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
|
||||
pbrepo.BindPanelRules(app)
|
||||
|
||||
fileRepo := pbrepo.NewFileRepository(app)
|
||||
jobRepo := pbrepo.NewTranscriptJobRepository(app)
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
textRepo := pbrepo.NewTextRepository(app)
|
||||
repos := service.Repositories{
|
||||
Records: recordRepo,
|
||||
Files: pbrepo.NewFileRepository(app),
|
||||
Texts: textRepo,
|
||||
Structures: pbrepo.NewStructureRepository(app),
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
}
|
||||
|
||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||
@@ -178,16 +188,16 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
trsService := service.NewTranscribeService(
|
||||
jobRepo,
|
||||
fileRepo,
|
||||
repos,
|
||||
metaviewer,
|
||||
&stubConverter{},
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
&TestTgSender{},
|
||||
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
||||
logger,
|
||||
)
|
||||
|
||||
handler := NewTranscribeHandler(jobRepo, trsService, logger)
|
||||
handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger)
|
||||
|
||||
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
|
||||
// обработчик, и проверка судит ту же цепочку, что и прод.
|
||||
@@ -256,16 +266,16 @@ func countFiles(t *testing.T, env *testEnv) int {
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// countJobs считает заведённые задачи расшифровки.
|
||||
// countJobs считает заведённые аудиозаписи.
|
||||
func countJobs(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
}
|
||||
|
||||
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
||||
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
||||
func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
|
||||
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
@@ -276,17 +286,18 @@ func jobWithFile(t *testing.T, env *testEnv) *entity.TranscribeJob {
|
||||
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
|
||||
// владельца больше не заводится, и фикстура без него описывала бы состояние,
|
||||
// которого в проде не бывает.
|
||||
file, err := repo.CreateLocal("sample.mp3", work, env.account.Id)
|
||||
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
job := &entity.TranscribeJob{
|
||||
State: entity.StateCreated,
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: &env.account.Id,
|
||||
FileID: &file.Id,
|
||||
record := &entity.AudioRecord{
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: &env.account.Id,
|
||||
OriginalFileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.jobRepo.Create(job))
|
||||
return job
|
||||
require.NoError(t, env.handler.recordRepo.Create(record))
|
||||
return record
|
||||
}
|
||||
|
||||
// storedContent читает содержимое файла из хранилища.
|
||||
@@ -327,21 +338,21 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, response.JobID)
|
||||
assert.Equal(t, entity.StateCreated, response.State)
|
||||
assert.Equal(t, entity.StateUploaded, response.State)
|
||||
|
||||
// Задача действительно заведена, а не только названа в ответе: иначе
|
||||
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||
require.Equal(t, 1, countJobs(t, env))
|
||||
|
||||
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
|
||||
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateCreated, job.State)
|
||||
require.NotNil(t, job.FileID)
|
||||
assert.NotEmpty(t, *job.FileID)
|
||||
assert.Equal(t, entity.StateUploaded, job.State)
|
||||
require.NotNil(t, job.OriginalFileID)
|
||||
assert.NotEmpty(t, *job.OriginalFileID)
|
||||
|
||||
// Содержимое лежит в хранилище одним файлом и целиком.
|
||||
require.Equal(t, 1, countFiles(t, env))
|
||||
assert.Equal(t, content, storedContent(t, env, *job.FileID))
|
||||
assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID))
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_NoFile(t *testing.T) {
|
||||
@@ -404,7 +415,7 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, response.JobID)
|
||||
assert.Equal(t, entity.StateCreated, response.State)
|
||||
assert.Equal(t, entity.StateUploaded, response.State)
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
|
||||
@@ -513,7 +524,7 @@ const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
|
||||
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
|
||||
// Когда долг закроют, правка будет здесь и одна.
|
||||
const (
|
||||
msgIntake = "Creating transcribe job"
|
||||
msgIntake = "Creating audio record"
|
||||
// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
|
||||
// логировать ошибку один раз, и повторная запись транспорта снята.
|
||||
msgIntakeErr = "Failed to get file info"
|
||||
@@ -603,15 +614,15 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
var response CreateTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
job, err := env.handler.jobRepo.GetByID(response.JobID, env.account.Id)
|
||||
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.FileID)
|
||||
require.NotNil(t, job.OriginalFileID)
|
||||
|
||||
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
|
||||
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
|
||||
// сохранённой при пустом журнале.
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
|
||||
assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён")
|
||||
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
|
||||
|
||||
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
|
||||
@@ -698,7 +709,7 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
|
||||
assert.Equal(t, job.Id, response.JobID)
|
||||
assert.Equal(t, entity.StateCreated, response.State)
|
||||
assert.Equal(t, entity.StateUploaded, response.State)
|
||||
assert.NotZero(t, response.CreatedAt)
|
||||
}
|
||||
|
||||
@@ -761,7 +772,7 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user