- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
411 lines
16 KiB
Go
411 lines
16 KiB
Go
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(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
|
|
"название чужой темы наружу не выходит")
|
|
}
|