приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -17,22 +17,105 @@ import (
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Ответ об одной записи — то, ради чего эндпойнт и существует; ниже судятся его
|
||||
// ветки: готовый текст, остановленная запись и отказ хранилища на чтении текста.
|
||||
// Карточка записи и её текст читаются порознь: шестичасовая расшифровка,
|
||||
// приехавшая вместе с шапкой, задерживает показ на мобильной сети на время,
|
||||
// которое человеку не нужно ждать.
|
||||
|
||||
// statusOf спрашивает состояние записи от имени её владельца.
|
||||
func statusOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
|
||||
// cardOf спрашивает карточку записи от имени её владельца.
|
||||
func cardOf(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))
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID, http.NoBody))
|
||||
return w
|
||||
}
|
||||
|
||||
// Готовая расшифровка доезжает до отправителя полем `transcription_text`, и
|
||||
// уходит в него **сырая** расшифровка: видов текста больше одного, и отдача
|
||||
// «последнего записанного» сделала бы ответ функцией порядка записи.
|
||||
func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
|
||||
// textOf спрашивает текст записи названного вида.
|
||||
func textOf(t *testing.T, env *testEnv, recordID, view string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID+"/text?view="+view, http.NoBody))
|
||||
return w
|
||||
}
|
||||
|
||||
// Карточка текста не несёт вовсе, а о его наличии сообщает перечнем доступных
|
||||
// видов. Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
|
||||
// достижимо, и один признак отправил бы приложение за репликами, которых нет.
|
||||
func TestRecordCard_CarriesNoTextButListsViews(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
|
||||
record.MoveToState(entity.StateDone)
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := cardOf(t, env, record.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var card RecordView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
|
||||
assert.Equal(t, entity.StateDone, card.State)
|
||||
assert.NotContains(t, w.Body.String(), "сырая расшифровка",
|
||||
"текст в карточку не кладётся: за ним идут отдельным адресом")
|
||||
require.NotNil(t, card.AvailableViews)
|
||||
assert.Equal(t, []string{entity.TextViewTranscript}, *card.AvailableViews)
|
||||
}
|
||||
|
||||
// Пока запись не дошла до текста, перечень доступных видов пуст. Пустой перечень
|
||||
// значит «текста ещё нет» — и это состояние, а не отсутствие записи.
|
||||
func TestRecordCard_NoTextYetGivesEmptyViews(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
w := cardOf(t, env, record.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var card RecordView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
|
||||
assert.Equal(t, entity.StateUploaded, card.State)
|
||||
assert.False(t, card.Halted, "запись в работе остановленной не значится")
|
||||
require.NotNil(t, card.AvailableViews, "поле есть на проводе даже когда текста нет")
|
||||
assert.Empty(t, *card.AvailableViews)
|
||||
}
|
||||
|
||||
// Остановленная запись отдаёт рубеж, на котором встала, признак остановки и её
|
||||
// причину. Этим держится инвариант проекта: опрос готовности убран, и карточка —
|
||||
// единственное место, где отправитель узнаёт о неудаче.
|
||||
func TestRecordCard_HaltedCarriesReason(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 := cardOf(t, env, record.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var card RecordView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
|
||||
assert.Equal(t, entity.StateNormalized, card.State, "рубеж тот, на котором запись встала")
|
||||
assert.True(t, card.Halted, "признак остановки виден владельцу записи")
|
||||
require.NotNil(t, card.HaltReason, "без причины признак не говорит, чего ждать")
|
||||
assert.Equal(t, entity.HaltReasonStepFailed, *card.HaltReason)
|
||||
assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
|
||||
"машинный текст отказа принадлежит журналу владельца сервиса, а не ответу")
|
||||
}
|
||||
|
||||
// Сырая расшифровка отдаётся своим видом, и только она: видов текста больше
|
||||
// одного, и отдача «последнего записанного» сделала бы ответ функцией порядка
|
||||
// записи, а не состояния записи.
|
||||
func TestRecordText_TranscriptView(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
@@ -45,61 +128,95 @@ func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
|
||||
|
||||
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)
|
||||
w := textOf(t, env, record.Id, entity.TextViewTranscript)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
var text TextView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
|
||||
|
||||
assert.Equal(t, entity.StateDone, response.State)
|
||||
require.NotNil(t, response.TranscriptionText, "готовый текст доехал до отправителя")
|
||||
assert.Equal(t, "сырая расшифровка", *response.TranscriptionText)
|
||||
assert.Equal(t, entity.TextViewTranscript, text.View)
|
||||
assert.Equal(t, "сырая расшифровка", text.Contents)
|
||||
assert.NotContains(t, w.Body.String(), "вычитанный текст",
|
||||
"вычитанный текст этим полем не подменяется: значение поля не должно меняться от того, успел ли необязательный шаг")
|
||||
"вычитанный текст этим видом не подменяется: у него своё значение перечня")
|
||||
}
|
||||
|
||||
// Остановленная запись отдаёт рубеж, на котором встала, и признак остановки
|
||||
// отдельным полем: отказ перестал быть состоянием, и без признака такая запись
|
||||
// выглядела бы обычной, стоящей на своём рубеже.
|
||||
func TestGetTranscribeJobStatus_HaltedIsVisible(t *testing.T) {
|
||||
// Реплики со временем — своё значение перечня, а не форма показа расшифровки:
|
||||
// они лежат структурой разбора и принадлежат записи, а не тексту.
|
||||
func TestRecordText_ReplicasView(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
record.MoveToState(entity.StateNormalized)
|
||||
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
|
||||
|
||||
structures := pbrepo.NewStructureRepository(env.app)
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{
|
||||
{StartMs: 0, EndMs: 1500, Text: "первая реплика"},
|
||||
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
record.StructureID = &structure.Id
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := statusOf(t, env, record.Id)
|
||||
w := textOf(t, env, record.Id, entity.TextViewReplicas)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
var text TextView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
|
||||
|
||||
assert.Equal(t, entity.StateNormalized, response.State, "рубеж тот, на котором запись встала")
|
||||
assert.True(t, response.Halted, "признак остановки виден отправителю")
|
||||
assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
|
||||
"машинный текст отказа принадлежит журналу владельца, а не ответу отправителю")
|
||||
assert.Equal(t, entity.TextViewReplicas, text.View)
|
||||
require.Len(t, text.Replicas, 2)
|
||||
assert.Equal(t, "первая реплика", text.Replicas[0].Text)
|
||||
assert.Equal(t, int64(1500), text.Replicas[0].EndMs, "у каждой реплики стоит её время")
|
||||
}
|
||||
|
||||
// Пока запись не дошла до текста, поля нет вовсе: пустая строка на его месте
|
||||
// читается как «расшифровка пуста».
|
||||
func TestGetTranscribeJobStatus_RunningRecordHasNoHaltedFlag(t *testing.T) {
|
||||
// «Текста этого вида ещё нет» обязано отличаться от «записи нет»: иначе человек
|
||||
// увидел бы «не найдено» на своей записи, загруженной минуту назад, — ровно тот
|
||||
// отказ, ради устранения которого заведён весь контракт.
|
||||
func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
w := statusOf(t, env, record.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
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, ""))
|
||||
|
||||
var response GetTranscribeJobResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||
// Расшифровка есть, структуры реплик нет — состояние достижимое: шаг
|
||||
// завершения пишет их несколькими операциями.
|
||||
notReady := textOf(t, env, record.Id, entity.TextViewReplicas)
|
||||
assert.Equal(t, http.StatusConflict, notReady.Code)
|
||||
|
||||
assert.Equal(t, entity.StateUploaded, response.State)
|
||||
assert.False(t, response.Halted, "запись в работе остановленной не значится")
|
||||
assert.Nil(t, response.TranscriptionText)
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(notReady.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeNotReady, body.Code)
|
||||
|
||||
missing := textOf(t, env, "nosuchrecordid", entity.TextViewTranscript)
|
||||
assert.Equal(t, http.StatusNotFound, missing.Code)
|
||||
|
||||
var missingBody ErrorBody
|
||||
require.NoError(t, json.Unmarshal(missing.Body.Bytes(), &missingBody))
|
||||
assert.Equal(t, CodeNotFound, missingBody.Code)
|
||||
assert.NotEqual(t, body.Code, missingBody.Code,
|
||||
"«ещё не готово» и «записи нет» ведут к разным действиям человека")
|
||||
}
|
||||
|
||||
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
|
||||
// умолчание сделало бы ответ функцией того, что успел записать конвейер.
|
||||
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
unknown := textOf(t, env, record.Id, "unknown-view")
|
||||
assert.Equal(t, http.StatusBadRequest, unknown.Code)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text", http.NoBody))
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "незаданный вид известным не считается")
|
||||
}
|
||||
|
||||
// failingTextRepo отказывает на чтении текста — так выглядит недоступное
|
||||
@@ -119,7 +236,7 @@ func (r *failingTextRepo) GetByID(string) (*entity.Text, error) {
|
||||
// приходит своим кодом, и владелец сервиса узнаёт об аварии из журнала — иначе
|
||||
// она читалась бы отправителю как «вашей записи не существует», а владельцем не
|
||||
// замечалась бы вовсе.
|
||||
func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
|
||||
func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
@@ -134,9 +251,10 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
|
||||
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
|
||||
// цепочка та же, что и в проде.
|
||||
journal := &journalBuffer{}
|
||||
handler := NewTranscribeHandler(
|
||||
handler := NewAppHandler(
|
||||
env.handler.recordRepo,
|
||||
&failingTextRepo{},
|
||||
pbrepo.NewStructureRepository(env.app),
|
||||
env.handler.trsService,
|
||||
slog.New(slog.NewTextHandler(journal, nil)),
|
||||
)
|
||||
@@ -147,7 +265,7 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
@@ -155,6 +273,22 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code,
|
||||
"отказ хранилища не выдаётся за отсутствие записи")
|
||||
assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят")
|
||||
assert.Contains(t, journal.String(), "Failed to read transcript",
|
||||
assert.Contains(t, journal.String(), "Failed to read text",
|
||||
"владелец сервиса узнаёт об аварии из журнала")
|
||||
}
|
||||
|
||||
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
|
||||
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
|
||||
func TestFormerAddressesAreGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
status := httptest.NewRecorder()
|
||||
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
|
||||
assert.Equal(t, http.StatusNotFound, status.Code)
|
||||
|
||||
intake := httptest.NewRecorder()
|
||||
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
|
||||
assert.Equal(t, http.StatusNotFound, intake.Code)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user