приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
)
|
||||
|
||||
// Пределы объявляются тем же значением, которое сервис применяет, а не его
|
||||
// копией. Приложение, знающее предел своей константой, расходится с сервером
|
||||
// молча — до первого отказа на записи, которую человек уже успел отправить.
|
||||
func TestConfig_LimitsAreTheAppliedOnes(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var config ConfigView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config))
|
||||
|
||||
// Потолок размера — то же число, которым ограничено тело запроса приёма и
|
||||
// которым сервис отвергает запись.
|
||||
assert.Equal(t, entity.MaxRecordSize, config.MaxRecordSizeBytes)
|
||||
assert.Equal(t, MaxPageLimit, config.MaxPageSize)
|
||||
assert.Equal(t, entity.MaxTopicsPerRecord, config.MaxTopicsPerRecord)
|
||||
assert.Equal(t, PollIntervalMs, config.PollIntervalMs)
|
||||
|
||||
// Перечень расширений — тот же, что сужает метку метрики, за вычетом
|
||||
// собственного умолчания сервиса: `audio` не формат, и подсказкой человеку
|
||||
// выходить не должно.
|
||||
assert.Equal(t, metrics.PublicFormats(), config.KnownExtensions)
|
||||
assert.NotContains(t, config.KnownExtensions, "audio",
|
||||
"умолчание сервиса форматом не является")
|
||||
assert.Contains(t, config.KnownExtensions, "mp3")
|
||||
}
|
||||
|
||||
// Кто вошёл — приложение узнаёт ответом: кука недоступна скриптам страницы, и
|
||||
// прочитать из неё имя оно не может вовсе. Адрес почты при этом наружу не идёт.
|
||||
func TestMe_CarriesAccountWithoutEmail(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/me", http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var me MeView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
|
||||
|
||||
assert.Equal(t, env.account.Id, me.ID)
|
||||
assert.NotContains(t, w.Body.String(), "person@example.com",
|
||||
"адрес почты принадлежит человеку, а не сервису")
|
||||
}
|
||||
|
||||
// Без сессии заведённая запись неотличима от неизвестной: иначе по разнице
|
||||
// ответов перебирается список заведённых записей.
|
||||
func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
existing := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
|
||||
unknown := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||
require.Equal(t, http.StatusUnauthorized, unknown.Code)
|
||||
assert.Equal(t, existing.Body.String(), unknown.Body.String(),
|
||||
"тело одно: по разнице ответов иначе перебирается список записей")
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(existing.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeUnauthorized, body.Code)
|
||||
}
|
||||
|
||||
// Имя файла отправителя доходит до своей колонки, а колонка заголовка остаётся
|
||||
// пустой: приём заголовков не сочиняет, а посчитанное языковой моделью название
|
||||
// легло бы поверх имени, если бы они делили одну колонку.
|
||||
func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, "разговор.mp3", []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
assert.Equal(t, "разговор.mp3", *record.OriginalFilename)
|
||||
assert.Nil(t, record.Title, "колонка заголовка у принятой записи пуста")
|
||||
|
||||
// Длительность и размер — снимок принятого, взятый приёмом.
|
||||
require.NotNil(t, record.DurationMs)
|
||||
assert.Equal(t, int64(42_000), *record.DurationMs)
|
||||
require.NotNil(t, record.SizeBytes)
|
||||
assert.Positive(t, *record.SizeBytes)
|
||||
}
|
||||
|
||||
// Имя длиннее предела доходит до записи обрезанным.
|
||||
//
|
||||
// Управляющие знаки этой проверкой не судятся, и причина внешняя: имя с ними
|
||||
// ломает разбор multipart раньше нашего кода — заголовок части становится
|
||||
// негодным, и запрос до обработчика не доезжает вовсе. Уборку знаков поэтому
|
||||
// судит проверка домена рядом, где живёт само правило.
|
||||
func TestIntake_LongFilenameIsTrimmed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
long := strings.Repeat("я", entity.MaxOriginalFilenameLen+50) + ".mp3"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, long, []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
|
||||
stored := *record.OriginalFilename
|
||||
assert.Len(t, []rune(stored), entity.MaxOriginalFilenameLen,
|
||||
"имя обрезано по пределу, и режется оно по знакам, а не по байтам")
|
||||
}
|
||||
|
||||
// Отказ по превышению потолка размера проходит через единую форму: прежде он
|
||||
// уходил телом ограничителя тела и читался как «сломался сервер».
|
||||
func TestIntake_TooLargeGoesThroughOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
status, body := mapDomainError(errTooLargeForTest())
|
||||
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, status)
|
||||
assert.Equal(t, CodeTooLarge, body.Code)
|
||||
require.NotNil(t, body.Limit, "предел уходит человеку числом")
|
||||
assert.Equal(t, entity.MaxRecordSize, *body.Limit)
|
||||
assert.NotEmpty(t, body.Message)
|
||||
|
||||
// И тот же предел объявлен адресом пределов — одним числом, а не двумя.
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody))
|
||||
var config ConfigView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config))
|
||||
assert.Equal(t, *body.Limit, config.MaxRecordSizeBytes)
|
||||
}
|
||||
|
||||
// Форма тела отказа одна на всех ветвях: код разбирает программа, сообщение
|
||||
// читает человек, сырого текста ошибки нет нигде.
|
||||
func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req func() *http.Request
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "записи нет",
|
||||
req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) },
|
||||
code: CodeNotFound,
|
||||
},
|
||||
{
|
||||
name: "негодный ввод",
|
||||
req: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/app/audiorecords?limit=0", http.NoBody)
|
||||
},
|
||||
code: CodeBadRequest,
|
||||
},
|
||||
{
|
||||
name: "негодная запись",
|
||||
req: func() *http.Request {
|
||||
return createMultipartRequestWithField(t, "wrong-field", "sample.mp3", []byte("данные"))
|
||||
},
|
||||
code: CodeBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, tc.req())
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
|
||||
assert.Equal(t, tc.code, body.Code, "код машиночитаем и из закрытого перечня")
|
||||
assert.NotEmpty(t, body.Message, "рядом с кодом стоит фраза для человека")
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
assert.Contains(t, raw, "error_code")
|
||||
assert.Contains(t, raw, "message")
|
||||
assert.NotContains(t, raw, "error", "прежнее поле ушло вместе с прежним контрактом")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Своё правило ограничителя частоты заведено под корнем приложения: правило
|
||||
// хранилища настроено на его собственный корень и наших адресов не покрывает.
|
||||
func TestRateLimitRuleCoversAppRoot(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var found int
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
found++
|
||||
assert.Positive(t, rule.MaxRequests)
|
||||
assert.Positive(t, rule.Duration)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно")
|
||||
assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён")
|
||||
|
||||
// Правило приводится к настройке **при каждом подъёме**, то есть на каждом
|
||||
// рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу,
|
||||
// и правила молча копились бы с каждой выкладкой.
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
again := 0
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
again++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе")
|
||||
}
|
||||
|
||||
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
|
||||
// в нём тоже: без этой проверки ветвь ни разу не исполнялась бы, а приложение не
|
||||
// предложило бы открыть готовый текст.
|
||||
func TestAvailableViewsCoverEveryKind(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||
require.NoError(t, err)
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
structures := pbrepo.NewStructureRepository(env.app)
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
record.LiteraryTextID = &literary.Id
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
record.StructureID = &structure.Id
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var card RecordView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
|
||||
require.NotNil(t, card.AvailableViews, "поле обязано быть на проводе")
|
||||
assert.ElementsMatch(t,
|
||||
[]string{entity.TextViewTranscript, entity.TextViewLiterary, entity.TextViewReplicas},
|
||||
*card.AvailableViews,
|
||||
"каждый готовый вид назван перечнем")
|
||||
|
||||
// И каждый названный вид действительно отдаётся своим адресом.
|
||||
for _, view := range *card.AvailableViews {
|
||||
got := textOf(t, env, record.Id, view)
|
||||
assert.Equal(t, http.StatusOK, got.Code, "вид %q обещан перечнем и обязан отдаваться", view)
|
||||
}
|
||||
}
|
||||
|
||||
// errTooLargeForTest — отказ по превышению потолка, каким его строит приём.
|
||||
func errTooLargeForTest() error {
|
||||
return contract.ErrRecordTooLarge
|
||||
}
|
||||
|
||||
// Отказ по превышению потолка размера проходит **настоящим путём**, а не вызовом
|
||||
// отображателя. Прежде проверка звала `mapDomainError` самодельной ошибкой и была
|
||||
// зелёной независимо от того, что происходит на проводе: предел тела срабатывает
|
||||
// слоем, до обработчика запрос не доходит, и отказ уходил телом библиотеки.
|
||||
func TestTooLargeOnTheRealPath(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("данные"))
|
||||
req.ContentLength = entity.MaxRecordSize + 1
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeTooLarge, body.Code, "код машиночитаем")
|
||||
require.NotNil(t, body.Limit, "предел уходит человеку числом")
|
||||
assert.Equal(t, entity.MaxRecordSize, *body.Limit)
|
||||
assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
|
||||
}
|
||||
|
||||
// Отказ ограничителя частоты тоже идёт единой формой: он рождается слоем ниже
|
||||
// обработчика, и без перевода приложение получило бы тело библиотеки на самом
|
||||
// частом отказе после превышения размера.
|
||||
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var last *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
last = httptest.NewRecorder()
|
||||
env.serve(last, httptest.NewRequest("GET", "/app/me", http.NoBody))
|
||||
if last.Code == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, last.Code, "ограничитель сработал")
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(last.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeTooManyRequests, body.Code)
|
||||
assert.NotEmpty(t, body.Message)
|
||||
}
|
||||
|
||||
// Объявленная частота опроса умещается в бюджет ограничителя с запасом: прежде
|
||||
// она равнялась всему бюджету, и любой соседний запрос в ту же секунду выводил
|
||||
// приложение за потолок — отказ, которого сервис сам же обещал избежать.
|
||||
func TestPollIntervalLeavesBudgetHeadroom(t *testing.T) {
|
||||
pollsPerWindow := int64(appRateWindowSec) * 1000 / PollIntervalMs
|
||||
|
||||
assert.Less(t, pollsPerWindow, int64(appRateMaxRequests),
|
||||
"опрос с объявленной частотой не выбирает бюджет целиком")
|
||||
assert.Positive(t, pollsPerWindow, "и при этом опрашивать вообще можно")
|
||||
}
|
||||
|
||||
// Длинное расширение из имени отправителя не роняет приём в «внутреннюю ошибку»:
|
||||
// хвост после последней точки задаёт отправитель, и без потолка имя `x.` с
|
||||
// четырьмястами знаками валит заведение временного файла.
|
||||
func TestIntake_AbsurdExtensionDoesNotBecomeInternalError(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, "x."+strings.Repeat("a", 400), []byte("данные")))
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code,
|
||||
"запись принята: абсурдное расширение заменено собственным умолчанием")
|
||||
assert.NotContains(t, env.journal.String(), "file name too long")
|
||||
}
|
||||
|
||||
// Неизвестный путь и неверный метод под корнем приложения тоже идут единой
|
||||
// формой. Слой формы их не покрывает: отказ «ничего не совпало» рождается
|
||||
// маршрутом корневой группы, к которому слои нашей группы не привязаны, — и без
|
||||
// своего перехвата форм отказа под корнем было бы две.
|
||||
func TestUnknownAddressUnderAppRootUsesOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{name: "неизвестный путь", method: "GET", path: "/app/nosuchendpoint"},
|
||||
{name: "неверный метод у списка", method: "DELETE", path: "/app/audiorecords"},
|
||||
{name: "неверный метод у пределов", method: "POST", path: "/app/config"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(tc.method, tc.path, http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeNotFound, body.Code, "код машиночитаем, а не тело библиотеки")
|
||||
assert.NotEmpty(t, body.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ссылка на текст без содержимого видом не считается. Иначе карточка обещала бы
|
||||
// вид, а адрес текста отвечал бы «ещё не готов» вечно: приложение опрашивало бы
|
||||
// его без конца, а человек видел бы завершённую запись, из которой текст
|
||||
// «вот-вот появится». Пустой ответ распознавания — состояние штатное.
|
||||
func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
record.TranscriptTextID = &empty.Id
|
||||
record.MoveToState(entity.StateDone)
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var card RecordView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
|
||||
require.NotNil(t, card.AvailableViews)
|
||||
assert.Empty(t, *card.AvailableViews,
|
||||
"ссылка есть, содержимого нет — вид доступным не считается")
|
||||
|
||||
// И адрес текста отвечает тем же: состоянием, а не обещанием.
|
||||
assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code)
|
||||
}
|
||||
Reference in New Issue
Block a user