package http import ( "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "testing" "github.com/pocketbase/pocketbase/apis" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "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 := 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) texts := pbrepo.NewTextRepository(env.app) 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 := 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 := 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 := 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, "")) // Расшифровка есть, структуры реплик нет — состояние достижимое: шаг // завершения пишет их несколькими операциями. 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_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 := 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, "")) // Обработчик пересобирается с отказывающим хранилищем текстов: остальная // цепочка та же, что и в проде. journal := &journalBuffer{} handler := NewAppHandler( env.handler.recordRepo, &failingTextRepo{}, pbrepo.NewStructureRepository(env.app), env.handler.trsService, slog.New(slog.NewTextHandler(journal, nil)), ) r, err := apis.NewRouter(env.app) require.NoError(t, err) r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil)) handler.Register(r) mux, err := r.BuildMux() require.NoError(t, err) 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", "владелец сервиса узнаёт об аварии из журнала") } // Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и // адрес, отвечающий по-старому, означал бы два дома у одного вопроса. 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) }