package http import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/entity" ) // pageOf спрашивает страницу записей от имени вошедшего. func pageOf(t *testing.T, env *testEnv, query string) PageView { t.Helper() w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords"+query, http.NoBody)) require.Equal(t, http.StatusOK, w.Code, "тело: %s", w.Body.String()) var page PageView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page)) return page } // acceptRecords заводит несколько записей приёмом — тем же путём, каким они // появляются в проде. func acceptRecords(t *testing.T, env *testEnv, n int) { t.Helper() for i := range n { w := httptest.NewRecorder() env.serve(w, createMultipartRequest(t, fmt.Sprintf("запись-%d.mp3", i), []byte("данные"))) require.Equal(t, http.StatusCreated, w.Code) } } // Страница отдаётся новыми сверху и несёт общее число записей. func TestList_NewestFirst(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 3) page := pageOf(t, env, "?limit=2") require.Len(t, page.Items, 2) assert.Equal(t, 3, page.TotalItems, "общее число не зависит от размера страницы") assert.Equal(t, "запись-2.mp3", *page.Items[0].OriginalFilename, "первой стоит заведённая последней") require.NotNil(t, page.NextCursor, "есть что читать дальше") } // Запись, заведённая между двумя страницами, окна не сдвигает: ключ задаёт // положение, а не смещение. Со смещением один элемент пришёл бы дважды, а другой // не пришёл бы никогда — и оба раза молча. func TestList_RecordAcceptedBetweenPagesDoesNotShiftWindow(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 4) first := pageOf(t, env, "?limit=2") require.Len(t, first.Items, 2) require.NotNil(t, first.NextCursor) // Человек загружает ещё одну запись, не закрыв список, — штатный сценарий // экрана загрузки. acceptRecords(t, env, 1) second := pageOf(t, env, "?limit=2&cursor="+*first.NextCursor) seen := map[string]bool{} for _, item := range first.Items { seen[item.ID] = true } for _, item := range second.Items { assert.False(t, seen[item.ID], "элемент первой страницы не приходит вторым разом") seen[item.ID] = true } // Ни одна из четырёх исходных записей не потеряна: дочитываем до конца. cursor := second.NextCursor for cursor != nil { page := pageOf(t, env, "?limit=2&cursor="+*cursor) for _, item := range page.Items { seen[item.ID] = true } cursor = page.NextCursor } assert.Len(t, seen, 4, "все четыре исходные записи дочитаны, ни одна не пропущена") // Пятая, заведённая уже после начала листания, стоит **выше** окна и потому // движением вперёд не приходит — это и есть искомое свойство ключа. Человек // видит её, перечитав первую страницу. fresh := pageOf(t, env, "?limit=2") assert.Equal(t, 5, fresh.TotalItems) assert.Equal(t, "запись-0.mp3", *fresh.Items[0].OriginalFilename, "свежая запись видна сверху при перечитывании") } // Записи с одинаковым временем заведения идут в устойчивом порядке: ключ // сортировки полный, а одного времени мало — у записей, принятых одним запросом, // оно совпадает. func TestList_EqualCreatedAtKeepsStableOrder(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 4) first := pageOf(t, env, "") second := pageOf(t, env, "") require.Len(t, first.Items, 4) for i := range first.Items { assert.Equal(t, first.Items[i].ID, second.Items[i].ID, "порядок не меняется от прогона к прогону") } } // Размер страницы сверх потолка усекается, а негодный отвергается: человек // попросил больше, чем сервис отдаёт, но просьба сама по себе не негодна. func TestList_PageSizeCeilingAndBadValue(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 2) page := pageOf(t, env, fmt.Sprintf("?limit=%d", MaxPageLimit+500)) assert.LessOrEqual(t, len(page.Items), MaxPageLimit) for _, bad := range []string{"0", "-3", "много"} { w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?limit="+bad, http.NoBody)) assert.Equal(t, http.StatusBadRequest, w.Code, "размер %q негоден", bad) } } // Ключ, который сервис не может прочитать, даёт отказ, а не первую страницу: // молчаливая отдача первой дала бы человеку архив, листающийся по кругу. // // Негодность у ключа двух родов, и обе ветви разбора судятся здесь: строка, // которая не декодируется вовсе, и строка, которая декодируется — то есть // подделывается легко, — но не несёт пары «время и идентификатор». func TestList_MalformedCursorIsRejected(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 1) cases := []struct { name string cursor string }{ { name: "не декодируется вовсе", cursor: "мусор", }, { name: "декодируется, но разделителя нет", cursor: base64.RawURLEncoding.EncodeToString([]byte("без-разделителя")), }, { name: "декодируется, но времени нет", cursor: base64.RawURLEncoding.EncodeToString([]byte("|только-идентификатор")), }, { name: "декодируется, но идентификатора нет", cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")), }, { name: "пара пуста целиком", cursor: base64.RawURLEncoding.EncodeToString([]byte("|")), }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?cursor="+tc.cursor, http.NoBody)) 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 TestTopicsResolveToNames(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 1) all := pageOf(t, env, "") require.Len(t, all.Items, 1) // Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его // задача языковой модели, которой ещё нет. topicID := newTopic(t, env, env.account.ID, "семейный архив") attachTopic(t, env, all.Items[0].ID, topicID) record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID) require.NoError(t, err) page := pageOf(t, env, "") require.Len(t, page.Items, 1) assert.Equal(t, []string{"семейный архив"}, page.Items[0].Topics, "страница отдаёт название темы, а не её идентификатор") card := httptest.NewRecorder() env.serve(card, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) require.Equal(t, http.StatusOK, card.Code) var view RecordView require.NoError(t, json.Unmarshal(card.Body.Bytes(), &view)) assert.Equal(t, []string{"семейный архив"}, view.Topics) } // Отбор различает три состояния, и остановленная запись приходит ровно в одном // из них. Надвое она выпала бы из обеих половин — исчезла бы из списка при любом // значении, хотя ради неё список и открывают. func TestList_ThreeStatesEachRecordOnce(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 3) all := pageOf(t, env, "") require.Len(t, all.Items, 3) repo := env.handler.recordRepo halted, err := repo.GetByID(all.Items[0].ID, env.account.ID) require.NoError(t, err) halted.Halt(entity.HaltReasonStuck, "застряла") require.NoError(t, repo.Save(halted, "")) done, err := repo.GetByID(all.Items[1].ID, env.account.ID) require.NoError(t, err) done.MoveToState(entity.StateDone) require.NoError(t, repo.Save(done, "")) counts := map[string]int{} for _, filter := range []string{"working", "halted", "done"} { page := pageOf(t, env, "?filter="+filter) for _, item := range page.Items { counts[item.ID]++ } } require.Len(t, counts, 3, "все три записи видны отбором") for id, seen := range counts { assert.Equal(t, 1, seen, "запись %s приходит ровно в одном состоянии", id) } // И остановленная приходит с причиной: без неё признак не говорит человеку, // чего ждать. haltedPage := pageOf(t, env, "?filter=halted") require.Len(t, haltedPage.Items, 1) require.NotNil(t, haltedPage.Items[0].HaltReason) assert.Equal(t, entity.HaltReasonStuck, *haltedPage.Items[0].HaltReason) } // Неизвестное состояние отбора — негодный ввод, а не пустая выборка. func TestList_UnknownFilterIsRejected(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?filter=неизвестно", http.NoBody)) assert.Equal(t, http.StatusBadRequest, w.Code) } // Чужих записей в странице нет. func TestList_ShowsOnlyOwnRecords(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 2) newSecondAccount(t, env) w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody) asUser(req, "stranger") env.mux.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var page PageView require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page)) assert.Empty(t, page.Items, "чужие записи в страницу не попадают") assert.Equal(t, 0, page.TotalItems) } // Список не тянет расшифровку: она лежит порознь от записи ровно затем, чтобы // чтение страницы её не читало. func TestList_DoesNotReadTranscript(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 1) all := pageOf(t, env, "") require.Len(t, all.Items, 1) repo := env.handler.recordRepo record, err := repo.GetByID(all.Items[0].ID, env.account.ID) require.NoError(t, err) const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР" texts := env.handler.textRepo transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker) require.NoError(t, err) record.TranscriptTextID = &transcript.Id require.NoError(t, repo.Save(record, "")) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) require.Equal(t, http.StatusOK, w.Code) assert.NotContains(t, w.Body.String(), marker, "текст расшифровки в страницу не попадает") } // Карточка и элемент страницы — одна форма: две формы одной вещи разошлись бы // молча, и экран, написанный по одной, ломался бы о другую. func TestCardAndPageItemShareOneShape(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) w := httptest.NewRecorder() env.serve(w, createMultipartRequest(t, "запись.mp3", []byte("данные"))) require.Equal(t, http.StatusCreated, w.Code) var intake []map[string]json.RawMessage require.NoError(t, json.Unmarshal(w.Body.Bytes(), &intake)) require.Len(t, intake, 1) item := httptest.NewRecorder() env.serve(item, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) var rawPage struct { Items []map[string]json.RawMessage `json:"items"` } require.NoError(t, json.Unmarshal(item.Body.Bytes(), &rawPage)) require.Len(t, rawPage.Items, 1) id := strings.Trim(string(intake[0]["id"]), `"`) card := httptest.NewRecorder() env.serve(card, httptest.NewRequest("GET", "/app/audiorecords/"+id, http.NoBody)) var rawCard map[string]json.RawMessage require.NoError(t, json.Unmarshal(card.Body.Bytes(), &rawCard)) // Карточка = элемент страницы плюс перечень доступных видов. assert.Equal(t, fieldNames(rawPage.Items[0]), fieldNames(rawCard, "available_views"), "карточка отличается от элемента страницы ровно перечнем видов") // Элемент ответа приёма = карточка плюс признак повтора. Перечень видов есть // у обоих: у свежей записи он пуст, но на проводе присутствует — отсутствие // поля и пустой перечень приложение не различит. assert.Equal(t, fieldNames(rawCard), fieldNames(intake[0], "duplicate"), "элемент ответа приёма отличается от карточки ровно признаком повтора") assert.Contains(t, intake[0], "available_views", "перечень видов есть и в ответе приёма, пустым") } // fieldNames отдаёт отсортированные имена полей за вычетом названных. func fieldNames(raw map[string]json.RawMessage, except ...string) []string { skip := map[string]bool{} for _, name := range except { skip[name] = true } out := []string{} for name := range raw { if !skip[name] { out = append(out, name) } } sortStrings(out) return out } func sortStrings(v []string) { for i := 1; i < len(v); i++ { for j := i; j > 0 && v[j] < v[j-1]; j-- { v[j], v[j-1] = v[j-1], v[j] } } } // Разрешение тем сужено владельцем: словарь тем свой у каждого человека — пара // «владелец и название» уникальна, — и без сужения название чужой темы приехало // бы в ответ, как только темы начнёт писать языковая модель. func TestForeignTopicDoesNotResolve(t *testing.T) { env := setupTestEnv(t, readableMetaViewer()) acceptRecords(t, env, 1) all := pageOf(t, env, "") require.Len(t, all.Items, 1) stranger := newSecondAccount(t, env) foreign := newTopic(t, env, stranger.ID, "ЧУЖАЯ-ТЕМА-МАРКЕР") attachTopic(t, env, all.Items[0].ID, foreign) w := httptest.NewRecorder() env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) require.Equal(t, http.StatusOK, w.Code) assert.NotContains(t, w.Body.String(), "ЧУЖАЯ-ТЕМА-МАРКЕР", "название чужой темы наружу не выходит") }