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) }