приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+178 -44
View File
@@ -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)
}