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

- адреса приложения переехали в своё пространство `/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
+76 -44
View File
@@ -69,7 +69,7 @@ func readableMetaViewer() *stubMetaViewer {
// рабочий каталог процесса проверки не трогают.
type testEnv struct {
mux http.Handler
handler *TranscribeHandler
handler *AppHandler
app core.App
journal *journalBuffer
// session — значение сессии вошедшего. Приём и опрос закрыты за
@@ -189,7 +189,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger,
)
handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger)
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
// обработчик, и проверка судит ту же цепочку, что и прод.
@@ -221,6 +221,16 @@ func createMultipartRequest(t *testing.T, fileName string, content []byte) *http
// createMultipartRequestWithField кладёт запись в поле с заданным именем —
// нужно, чтобы построить форму без поля `audio`.
func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request {
return createMultipartRequestAtWithField(t, "/app/audiorecords", field, fileName, content)
}
// createMultipartRequestAt собирает тот же запрос по названному адресу — нужно
// проверке, судящей убранные адреса.
func createMultipartRequestAt(t *testing.T, path, fileName string, content []byte) *http.Request {
return createMultipartRequestAtWithField(t, path, "audio", fileName, content)
}
func createMultipartRequestAtWithField(t *testing.T, path, field, fileName string, content []byte) *http.Request {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
@@ -233,12 +243,23 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
err = writer.Close()
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/audio", &buf)
req := httptest.NewRequest("POST", path, &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
// intakeItemOf разбирает ответ приёма и отдаёт единственный его элемент. Ответ
// списком всегда, даже на один файл: форма согласована один раз и вперёд.
func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
t.Helper()
var items []IntakeItem
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &items))
require.Len(t, items, 1)
return items[0]
}
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFileNames(t *testing.T, env *testEnv) []string {
records, err := env.app.FindAllRecords(migrations.FilesCollection)
@@ -316,27 +337,32 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code)
// Имена полей ответа нормативны: контракт HTTP API объявлен необратимым.
// Судим по сырому JSON — разбор в CreateTranscribeJobResponse переименовал
// бы тег вместе с ожиданием, и проверка не смогла бы упасть.
var raw map[string]json.RawMessage
err := json.Unmarshal(w.Body.Bytes(), &raw)
// Имена полей ответа нормативны: контракт объявлен необратимым, а экраны
// строятся на этих именах. Судим по сырому JSON — разбор в структуру
// переименовал бы тег вместе с ожиданием, и проверка не смогла бы упасть.
//
// Ответ — **список**, даже когда файл в запросе один: форма согласована
// вперёд, чтобы приём нескольких файлов и распознавание повтора её не
// переписывали.
var rawItems []map[string]json.RawMessage
err := json.Unmarshal(w.Body.Bytes(), &rawItems)
require.NoError(t, err)
assert.Contains(t, raw, "job_id")
assert.Contains(t, raw, "status")
require.Len(t, rawItems, 1)
assert.Contains(t, rawItems[0], "id")
assert.Contains(t, rawItems[0], "state")
assert.Contains(t, rawItems[0], "duplicate", "место под признак повтора заведено вперёд")
assert.NotContains(t, rawItems[0], "job_id", "прежние имена полей убраны вместе с опросом")
var response CreateTranscribeJobResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
response := intakeItemOf(t, w)
assert.NotEmpty(t, response.JobID)
assert.NotEmpty(t, response.ID)
assert.Equal(t, entity.StateUploaded, response.State)
// Задача действительно заведена, а не только названа в ответе: иначе
// отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env))
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, job.State)
require.NotNil(t, job.OriginalFileID)
@@ -359,7 +385,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
req: func(t *testing.T) *http.Request {
// Запрос строится так, как его видит сервер: у пришедшего по
// проводу тело не бывает пустым указателем.
return httptest.NewRequest("POST", "/api/audio", http.NoBody)
return httptest.NewRequest("POST", "/app/audiorecords", http.NoBody)
},
},
{
@@ -379,11 +405,12 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
require.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]string
var response ErrorBody
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "No audio file provided", response["error"])
assert.Equal(t, CodeBadRequest, response.Code)
assert.NotEmpty(t, response.Message, "рядом с кодом стоит фраза для человека")
assert.Equal(t, 0, countFiles(t, env))
assert.Equal(t, 0, countJobs(t, env))
})
@@ -402,11 +429,9 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
response := intakeItemOf(t, w)
assert.NotEmpty(t, response.JobID)
assert.NotEmpty(t, response.ID)
assert.Equal(t, entity.StateUploaded, response.State)
}
@@ -492,15 +517,19 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
// Негодная запись — отказ по причине, а не по месту: прежде здесь стоял
// `500`, и «файл не читается» приходило человеку как «сломался сервер».
require.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]string
var response ErrorBody
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Причина отказа принадлежит журналу, а не отправителю.
assert.Equal(t, "Failed to create transcibe job", response["error"])
assert.Equal(t, CodeBadRequest, response.Code)
assert.NotEmpty(t, response.Message)
assert.NotContains(t, w.Body.String(), "не удалось прочитать запись")
assert.NotContains(t, w.Body.String(), "transcibe", "опечатка ушла вместе с прежним текстом")
assert.Equal(t, 0, countJobs(t, env))
}
@@ -555,7 +584,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Equal(t, http.StatusBadRequest, w.Code)
journal := env.journal.String()
@@ -603,10 +632,9 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
response := intakeItemOf(t, w)
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
require.NoError(t, err)
require.NotNil(t, job.OriginalFileID)
@@ -690,19 +718,19 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
job := jobWithFile(t, env)
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
var response RecordView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, job.Id, response.JobID)
assert.Equal(t, job.Id, response.ID)
assert.Equal(t, entity.StateUploaded, response.State)
assert.NotZero(t, response.CreatedAt)
assert.NotEmpty(t, response.CreatedAt)
}
func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
@@ -710,44 +738,48 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
job := jobWithFile(t, env)
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Судим по сырому JSON: пустая строка на месте отсутствующего текста
// читается клиентом как «расшифровка пуста», и разобранная структура
// эти два случая не различает.
// Судим по сырому JSON: имена полей карточки нормативны, а разбор в структуру
// переименовал бы тег вместе с ожиданием — и проверка не смогла бы упасть.
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
assert.Contains(t, raw, "job_id")
assert.Contains(t, raw, "status")
assert.Contains(t, raw, "id")
assert.Contains(t, raw, "state")
assert.Contains(t, raw, "created_at")
assert.NotContains(t, raw, "transcription_text")
assert.Contains(t, raw, "original_filename")
assert.Contains(t, raw, "duration_ms")
assert.Contains(t, raw, "size_bytes")
assert.NotContains(t, raw, "transcription_text", "текст читается своим адресом")
assert.NotContains(t, raw, "job_id", "прежние имена ушли вместе с опросом готовности")
}
func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
req := httptest.NewRequest("GET", "/app/audiorecords/non-existent-id", http.NoBody)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
var response map[string]string
var response ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, "Job not found", response["error"])
assert.Equal(t, CodeNotFound, response.Code, "код отказа машиночитаем")
assert.NotEmpty(t, response.Message, "и рядом с ним фраза для человека")
}
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча —
// карточкой записи. Приём на контексте запроса терял бы такую запись молча —
// решение владельца от 2026-08-13.
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())