приём и чтение записей сведены к одному контракту приложения

- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
This commit is contained in:
av
2026-08-15 13:51:23 +03:00
parent 79ff12548f
commit 3a2da3004b
55 changed files with 5506 additions and 466 deletions
+436
View File
@@ -0,0 +1,436 @@
package http
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"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/adapter/repo/pocketbase/migrations"
"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-15 10:00:00.000Z|")),
},
{
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)
// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
// задача языковой модели, которой ещё нет.
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
require.NoError(t, err)
topic := core.NewRecord(topics)
topic.Set("owner", env.account.Id)
topic.Set("name", "семейный архив")
require.NoError(t, env.app.Save(topic))
repo := pbrepo.NewAudioRecordRepository(env.app)
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
require.NoError(t, err)
record.TopicIDs = []string{topic.Id}
require.NoError(t, repo.Save(record, ""))
// Правку тем конвейер не делает, поэтому кладём их тем же путём, каким это
// сделает задача языковой модели, — прямым сохранением записи коллекции.
raw, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
raw.Set("topics", []string{topic.Id})
require.NoError(t, env.app.Save(raw))
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 := pbrepo.NewAudioRecordRepository(env.app)
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)
_, stranger := newSecondAccount(t, env.app)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: 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 := pbrepo.NewAudioRecordRepository(env.app)
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
require.NoError(t, err)
const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
texts := pbrepo.NewTextRepository(env.app)
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.app)
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
require.NoError(t, err)
foreign := core.NewRecord(topics)
foreign.Set("owner", stranger.Id)
foreign.Set("name", "ЧУЖАЯ-ТЕМА-МАРКЕР")
require.NoError(t, env.app.Save(foreign))
raw, err := env.app.FindRecordById(migrations.RecordsCollection, all.Items[0].ID)
require.NoError(t, err)
raw.Set("topics", []string{foreign.Id})
require.NoError(t, env.app.Save(raw))
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(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
"название чужой темы наружу не выходит")
}