- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
361 lines
17 KiB
Go
361 lines
17 KiB
Go
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), "прежний адрес приёма завёл запись")
|
|
}
|