package http import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Проверки разграничения записей по владельцу. Все идут через собранную // поверхность: сужение живёт в хранилище, но судится по тому, что видит // отправитель. // unknownRecordID — идентификатор годного вида, которого нет ни у одной записи. const unknownRecordID = "00000000000000000000000000" // newSecondAccount заводит вторую учётную запись со своим логином. func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount { t.Helper() account, created, err := env.users.EnsureUser(contract.Identity{ Login: "stranger", Name: "Посторонний", Email: "stranger@example.com", }) require.NoError(t, err) require.True(t, created) return account } // Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница // ответов обратила бы чтение в перебор — по ней считывается, какие записи // заведены. func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) newSecondAccount(t, env) foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id) unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID) require.Equal(t, http.StatusNotFound, foreign.Code, "чужая запись не отдаётся") assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора") assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же") assert.NotContains(t, foreign.Body.String(), entity.StateUploaded) } // Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт // на границе, и до базы такой запрос не доходит вовсе. func TestRecordCard_MalformedIDLooksLikeUnknown(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) malformed := env.getOwn("/app/audiorecords/не-идентификатор") unknown := env.getOwn("/app/audiorecords/" + unknownRecordID) assert.Equal(t, http.StatusNotFound, malformed.Code) assert.JSONEq(t, unknown.Body.String(), malformed.Body.String()) } // Владельцем принятой записи становится узнанный предъявитель — и у записи, и у // её копии. func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись"))) require.Equal(t, http.StatusCreated, w.Code) response := intakeItemOf(t, w) var owner string require.NoError(t, env.db.Reader(). QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner)) assert.Equal(t, env.account.ID, owner, "владелец записи — предъявитель") var fileOwner string require.NoError(t, env.db.Reader(). QueryRow("SELECT owner_id FROM files WHERE record_id = ?", response.ID).Scan(&fileOwner)) assert.Equal(t, env.account.ID, fileOwner, "владелец копии — он же") } // Владельца не задают запросом: своё значение в форме на результат не влияет. func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) stranger := newSecondAccount(t, env) req := createMultipartRequest(t, "sample.mp3", []byte("запись")) query := req.URL.Query() query.Set("owner", stranger.ID) req.URL.RawQuery = query.Encode() w := httptest.NewRecorder() env.serve(w, req) require.Equal(t, http.StatusCreated, w.Code) response := intakeItemOf(t, w) var owner string require.NoError(t, env.db.Reader(). QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner)) assert.Equal(t, env.account.ID, owner) } // TestFileDownload_NarrowedByOwner — **критерий приёмки**: чужой файл недостижим // по прямой ссылке, и ответ на него тот же, что и на несуществующую запись. // // Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы // наступить, у сервиса не осталось — значений на предъявителя он не выдаёт. func TestFileDownload_NarrowedByOwner(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) newSecondAccount(t, env) link := "/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyOriginal mine := env.getOwn(link) require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся") assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым") foreign := env.getAs("stranger", link) unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"="+CopyOriginal) assert.Equal(t, http.StatusNotFound, foreign.Code, "чужой файл не отдаётся") assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестной записи") assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же") assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет") } // Негодное значение копии у чужой записи неотличимо от неизвестной записи: // владение судится **до** разбора значения копии. Разойдись эти ответы — по их // разнице перебирался бы список заведённых записей одним негодным параметром. func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) newSecondAccount(t, env) foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id+"/file?"+CopyParam+"=whatever") unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"=whatever") missing := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file") assert.Equal(t, http.StatusNotFound, foreign.Code) assert.Equal(t, unknown.Code, foreign.Code) assert.JSONEq(t, unknown.Body.String(), foreign.Body.String()) assert.JSONEq(t, missing.Body.String(), foreign.Body.String(), "незаданная копия у чужой записи отвечает не как неизвестная запись") } // Копия, которой у записи ещё нет, отвечает отказом состояния, а не «не // найдено»: иначе человек увидел бы «не найдено» на своей записи, загруженной // минуту назад. func TestFileDownload_MissingCopyIsConflict(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) w := env.getOwn("/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyNormalized) require.Equal(t, http.StatusConflict, w.Code) var body ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, CodeNotReady, body.Code) unknown := env.getOwn("/app/audiorecords/" + unknownRecordID + "/file?" + CopyParam + "=" + CopyOriginal) assert.NotEqual(t, unknown.Code, w.Code, "отказ состояния слился с ответом на неизвестную запись") } // Копия, которой сервис не знает, и незаданная копия у **своей** записи дают // отказ по негодному вводу. func TestFileDownload_UnknownCopyIsBadRequest(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) for name, query := range map[string]string{ "копия неизвестна": "?" + CopyParam + "=whatever", "копия не названа": "", } { t.Run(name, func(t *testing.T) { w := env.getOwn("/app/audiorecords/" + job.Id + "/file" + query) require.Equal(t, http.StatusBadRequest, w.Code) var body ErrorBody require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, CodeBadRequest, body.Code) }) } } // Проигрыватель просит кусок записи: ответ несёт запрошенный кусок, а не файл // целиком. func TestFileDownload_RangeGivesRequestedSlice(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) const content = "0123456789abcdef" created := httptest.NewRecorder() env.serve(created, createMultipartRequest(t, "sample.mp3", []byte(content))) require.Equal(t, http.StatusCreated, created.Code) server := env.liveServer(t) link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal res := env.liveRequest(t, server, link, map[string]string{"Range": "bytes=4-9"}) require.Equal(t, http.StatusPartialContent, res.StatusCode) assert.Equal(t, "456789", string(res.Body), "ответ несёт не запрошенный кусок") assert.Equal(t, int64(len("456789")), res.ContentLength, "длина ответа — длина файла целиком") assert.Equal(t, "bytes 4-9/16", res.Header.Get("Content-Range")) } // TestFileDownload_BadRangeIsOurFailureForm — **критерий приёмки**: негодный // диапазон приходит обычным отказом сервиса, а не `416` телом библиотеки. // // Проверка идёт настоящим HTTP-запросом через поднятую цепочку слоёв: вызовом // отображателя ошибки это не проверяется — код `416` рождается не в нём. func TestFileDownload_BadRangeIsOurFailureForm(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) created := httptest.NewRecorder() env.serve(created, createMultipartRequest(t, "sample.mp3", []byte("0123456789"))) require.Equal(t, http.StatusCreated, created.Code) server := env.liveServer(t) link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal cases := map[string]string{ "неудовлетворимый диапазон": "bytes=99999999-", "два диапазона в запросе": "bytes=0-1,4-5", } for name, rangeHeader := range cases { t.Run(name, func(t *testing.T) { res := env.liveRequest(t, server, link, map[string]string{"Range": rangeHeader}) require.Equal(t, http.StatusBadRequest, res.StatusCode, "ответ не `400`") assert.NotContains(t, res.Header.Get("Content-Type"), "multipart", "сервис отдал ответ из нескольких частей") var body map[string]any require.NoError(t, json.Unmarshal(res.Body, &body)) assert.Contains(t, body, "error_code") assert.Contains(t, body, "message") }) } } // Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при сохранении, // строится из имени, данного отправителем. func TestFileDownload_CarriesNoStoredName(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) created := httptest.NewRecorder() env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("запись"))) require.Equal(t, http.StatusCreated, created.Code) recordID := intakeItemOf(t, created).ID w := env.getOwn("/app/audiorecords/" + recordID + "/file?" + CopyParam + "=" + CopyOriginal) require.Equal(t, http.StatusOK, w.Code) names := storedFileNames(t, env) require.Len(t, names, 1) headers := w.Result().Header for _, value := range headers.Values("Content-Disposition") { assert.NotContains(t, value, names[0], "имя файла на диске уехало в ответ") } assert.Contains(t, strings.Join(headers.Values("Content-Disposition"), " "), "%D1%80", "имя, данное отправителем, в ответ не попало") } // Перечня доступных копий карточка не объявляет: копий две, и каждая выводится // из рубежа записи, который карточка несёт и так. func TestRecordCard_HasNoCopyList(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) job := jobWithFile(t, env) w := env.getOwn("/app/audiorecords/" + job.Id) require.Equal(t, http.StatusOK, w.Code) var raw map[string]json.RawMessage require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw)) assert.NotContains(t, raw, "available_copies") assert.NotContains(t, raw, "copies") }