приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user