package http import ( "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Карточка записи и её текст читаются порознь: шестичасовая расшифровка, // приехавшая вместе с шапкой, задерживает показ на мобильной сети на время, // которое человеку не нужно ждать. // cardOf спрашивает карточку записи от имени её владельца. func cardOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder { t.Helper() w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID, http.NoBody)) return w } // 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 := env.handler.textRepo 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) texts := env.handler.textRepo 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 require.NoError(t, env.handler.recordRepo.Save(record, "")) w := textOf(t, env, record.Id, entity.TextViewTranscript) require.Equal(t, http.StatusOK, w.Code) var text TextView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text)) assert.Equal(t, entity.TextViewTranscript, text.View) assert.Equal(t, "сырая расшифровка", text.Contents) assert.NotContains(t, w.Body.String(), "вычитанный текст", "вычитанный текст этим видом не подменяется: у него своё значение перечня") } // Реплики со временем — своё значение перечня, а не форма показа расшифровки: // они лежат структурой разбора и принадлежат записи, а не тексту. func TestRecordText_ReplicasView(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) structures := env.handler.structureRepo 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 := textOf(t, env, record.Id, entity.TextViewReplicas) require.Equal(t, http.StatusOK, w.Code) var text TextView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text)) 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 TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) texts := env.handler.textRepo transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") require.NoError(t, err) record.TranscriptTextID = &transcript.Id require.NoError(t, env.handler.recordRepo.Save(record, "")) // Расшифровка есть, структуры реплик нет — состояние достижимое: шаг // завершения пишет их несколькими операциями. notReady := textOf(t, env, record.Id, entity.TextViewReplicas) assert.Equal(t, http.StatusConflict, notReady.Code) 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_EveryNotReadyBranchIsOneAnswer(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) notReady := func(t *testing.T, record *entity.AudioRecord, view string) { t.Helper() w := textOf(t, env, record.Id, view) require.Equal(t, http.StatusConflict, w.Code, view) var body ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, CodeNotReady, body.Code, view) assert.NotEmpty(t, body.Message, view) } t.Run("текста нет вовсе", func(t *testing.T) { record := jobWithFile(t, env) notReady(t, record, entity.TextViewTranscript) notReady(t, record, entity.TextViewLiterary) notReady(t, record, entity.TextViewReplicas) }) t.Run("текст заведён пустым", func(t *testing.T) { record := jobWithFile(t, env) transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "") require.NoError(t, err) record.TranscriptTextID = &transcript.Id literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "") require.NoError(t, err) record.LiteraryTextID = &literary.Id require.NoError(t, env.handler.recordRepo.Save(record, "")) notReady(t, record, entity.TextViewTranscript) notReady(t, record, entity.TextViewLiterary) }) t.Run("структура заведена без единой реплики", func(t *testing.T) { record := jobWithFile(t, env) structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{}) require.NoError(t, err) record.StructureID = &structure.Id require.NoError(t, env.handler.recordRepo.Save(record, "")) notReady(t, record, entity.TextViewReplicas) }) } // Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу: // умолчание сделало бы ответ функцией того, что успел записать конвейер. 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 отказывает на чтении текста — так выглядит недоступное // хранилище. Битую ссылку схема завести не даёт (связь проверяется при // сохранении), и это её защита, а не пробел: остаётся отказ самого чтения. 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 TestRecordText_ReadFailureIsNotANotFound(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) texts := env.handler.textRepo 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{} logger := slog.New(slog.NewTextHandler(journal, nil)) handler := NewAppHandler( env.handler.recordRepo, &failingTextRepo{}, env.handler.structureRepo, env.handler.fileRepo, env.handler.trsService, logger, ) mounts := ServiceMounts( AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger), http.NotFoundHandler(), ) mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger) req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody) asUser(req, env.login) 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 text", "владелец сервиса узнаёт об аварии из журнала") } // Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и // адрес, отвечающий по-старому, означал бы два дома у одного вопроса. // // Отвечают они теперь тем же, чем отвечает всякий путь вне корней сервиса, — // разметкой приложения: корень `/api` перестал существовать вместе со // встроенным хранилищем, и резервировать имя за отказом сервис не берётся. func TestFormerAddressesAreGone(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) record := jobWithFile(t, env) before := countJobs(t, env) status := httptest.NewRecorder() env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)) assert.Equal(t, http.StatusOK, status.Code) assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку") assert.NotContains(t, status.Body.String(), entity.StateUploaded, "прежний адрес отдал данные записи") intake := httptest.NewRecorder() env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные"))) assert.Equal(t, http.StatusMethodNotAllowed, intake.Code) assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись") }