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

- адреса приложения переехали в своё пространство `/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
@@ -0,0 +1,94 @@
package migrations
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608150001 заводит у аудиозаписи три колонки, которые показывает список
// приложения: имя файла, данное отправителем, длительность и размер принятого.
//
// Шаг один на все три намеренно. Применённый шаг не переписывается, и три шага
// вместо одного стоили бы трёх необратимых решений там, где хватает одного.
//
// **Имя файла ложится своей колонкой, а не в заголовок.** Заголовок несёт
// название, которое дал человек либо посчитала языковая модель; имя файла — то,
// по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
// смысла посчитанное название затирало бы имя, и вернуть затёртое было бы
// неоткуда.
//
// **Длительность и размер дублируют строку файла, и это решение владельца от
// 2026-08-15.** Равенство между ними не поддерживается никем: на записи лежит
// снимок принятого, взятый приёмом один раз, на файле — величины той копии,
// которой файл является сейчас. Расхождение — не поломка, а разные вопросы;
// норму держит capability `storage`.
//
// Единица стоит в имени колонки, а не в комментарии: расхождение «секунды против
// миллисекунд» между колонкой, ответом списка и объявленным пределом не увидит
// ни компилятор, ни гейт — оба конца числа.
func up202608150001(app core.App) error {
records, err := app.FindCollectionByNameOrId(RecordsCollection)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
}
records.Fields.Add(
// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому
// приём режет его по пределу и убирает управляющие знаки прежде, чем
// сохранить. Схема держит потолок вторым рубежом: значение сверх него
// отвергается хранилищем, а не доезжает до экрана.
&core.TextField{Name: "original_filename", Max: entity.MaxOriginalFilenameLen},
// Длительность и размер принятого. «Неизвестно» колонки не выражают:
// числовая колонка хранилища пустого значения не держит, пустое кладётся
// нулём. Обе ставит приём и ставит всегда — запись с непрочитанными
// метаданными отвергается отказом и не заводится. Решение владельца
// 2026-08-15.
&core.NumberField{Name: "duration_ms", OnlyInt: true, Min: ptr(0.0)},
&core.NumberField{Name: "size_bytes", OnlyInt: true, Min: ptr(0.0)},
)
// Индекс под ленту приложения. Единственный прежний индекс — по рубежу и
// признаку остановки — заведён под захват воркера и выборке владельца не
// помогает ничем: страница сканирует таблицу целиком и досортировывает
// результат во временном дереве.
//
// Замер на этом же изменении: рост архива с 5 тысяч строк до 200 тысяч —
// сорокакратный — растит время одной страницы владельца в двадцать-тридцать
// раз, хотя записей у него всё те же сорок. Цена растёт с **чужими**
// записями, потому что сервис объявлен архивом и хранит их бессрочно.
//
// Порядок колонок повторяет порядок выборки: сужение по владельцу, затем
// сортировка «новыми сверху» полным ключом.
records.AddIndex("idx_audio_records_owner_feed", false, "owner, created DESC, id DESC", "")
// Отбор тремя состояниями сужает по владельцу вместе с рубежом и признаком
// остановки — своим индексом, потому что ведущей колонкой здесь владелец.
records.AddIndex("idx_audio_records_owner_state", false, "owner, state, halted_at", "")
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to add contract columns to %s: %w", RecordsCollection, err)
}
return nil
}
// down202608150001 снимает три колонки. Данные в них при этом теряются, и
// восстановить их неоткуда: имя файла отправителя нигде больше не хранится.
func down202608150001(app core.App) error {
records, err := app.FindCollectionByNameOrId(RecordsCollection)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
}
for _, name := range []string{"original_filename", "duration_ms", "size_bytes"} {
records.Fields.RemoveByName(name)
}
records.RemoveIndex("idx_audio_records_owner_feed")
records.RemoveIndex("idx_audio_records_owner_state")
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to drop contract columns from %s: %w", RecordsCollection, err)
}
return nil
}
@@ -50,6 +50,7 @@ func init() {
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go")
}
func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,201 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// defaultListLimit — умолчание, к которому приводится непозитивный предел.
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
const defaultListLimit = 30
// List отдаёт страницу записей владельца, новыми сверху.
//
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
// элемент между ними не пришёл бы никогда — и оба раза молча.
//
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
// определён вовсе.
//
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
// колонками самой записи.
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
// Пустой владелец не совпадает ни с одной записью. Правило записано со
// стороны спрашивающего: обязательность, которую держит одна лишь схема,
// пустую строку пропустила бы.
if q.OwnerID == "" {
return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
}
// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
// вызывающий с забытым полем структуры получил бы панику, а восстановления у
// воркеров нет вовсе.
if q.Limit <= 0 {
q.Limit = defaultListLimit
}
collection, err := findCollection(repo.app, migrations.RecordsCollection)
if err != nil {
return nil, err
}
filter := dbx.HashExp{"owner": q.OwnerID}
conditions := []dbx.Expression{filter}
state, err := stateCondition(q.Filter)
if err != nil {
return nil, err
}
if state != nil {
conditions = append(conditions, state)
}
total, err := repo.countRecords(conditions)
if err != nil {
return nil, err
}
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
// — строго меньше по идентификатору. Идентификаторы хранилища монотонны в
// пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и
// этого довольно: задача ключа — не пропустить и не повторить.
if q.Cursor != nil {
conditions = append(conditions, dbx.Or(
dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
dbx.And(
dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}),
),
))
}
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
// следующая страница» без второго запроса и без вычислений по общему числу,
// которое к этому моменту могло измениться.
records := []*core.Record{}
err = repo.app.RecordQuery(collection).
AndWhere(dbx.And(conditions...)).
OrderBy("created DESC", "id DESC").
Limit(int64(q.Limit) + 1).
All(&records)
if err != nil {
return nil, fmt.Errorf("failed to list audio records: %w", err)
}
page := &contract.RecordPage{TotalItems: total}
if len(records) > q.Limit {
last := records[q.Limit-1]
page.NextCursor = &contract.RecordCursor{
CreatedAt: last.GetDateTime("created").String(),
ID: last.Id,
}
records = records[:q.Limit]
}
page.Items = make([]*entity.AudioRecord, 0, len(records))
for _, record := range records {
page.Items = append(page.Items, recordToAudioRecord(record))
}
return page, nil
}
// stateCondition переводит состояние отбора в условие запроса.
//
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
// молча поменял бы состав всех трёх состояний.
func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) {
if filter == nil {
return nil, nil
}
notHalted := dbx.NewExp("halted_at = ''")
halted := dbx.NewExp("halted_at != ''")
switch *filter {
case entity.ListFilterHalted:
return halted, nil
case entity.ListFilterWorking:
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil
case entity.ListFilterDone:
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil
}
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
// под именем отбора — и заметить это было бы нечем.
return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
}
func stageNameValues(stages []entity.Stage) []any {
names := entity.StageNames(stages)
out := make([]any, 0, len(names))
for _, n := range names {
out = append(out, n)
}
return out
}
func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) {
var counter struct {
Total int `db:"total"`
}
err := repo.app.RecordQuery(migrations.RecordsCollection).
Select("count(*) as total").
AndWhere(dbx.And(conditions...)).
One(&counter)
if err != nil {
return 0, fmt.Errorf("failed to count audio records: %w", err)
}
return counter.Total, nil
}
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
// не по запросу на запись: страница в сотню записей иначе стоила бы сотни
// обращений к хранилищу.
//
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
// чего контракт согласуется один раз.
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
out := map[string]string{}
if len(ids) == 0 || ownerID == "" {
return out, nil
}
values := make([]any, 0, len(ids))
for _, id := range ids {
values = append(values, id)
}
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека —
// пара «владелец и название» уникальна, — и разрешение без сужения отдало бы
// название чужой темы, как только темы начнёт писать языковая модель.
records := []*core.Record{}
err := repo.app.RecordQuery(migrations.TopicsCollection).
AndWhere(dbx.HashExp{"owner": ownerID}).
AndWhere(dbx.In("id", values...)).
All(&records)
if err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
for _, record := range records {
out[record.Id] = record.GetString("name")
}
return out, nil
}
@@ -54,6 +54,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
record.Set("source", r.Source)
record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief))
// Имя файла отправителя, длительность и размер кладёт приём и только он: это
// снимок принятого, и конвейер его не пересчитывает. В applyOwnedByPipeline их
// нет намеренно — снимок шага, записанный поверх, стёр бы их молча.
record.Set("original_filename", derefString(r.OriginalFilename))
record.Set("duration_ms", numberOrZero(r.DurationMs))
record.Set("size_bytes", numberOrZero(r.SizeBytes))
// Темы кладутся при заведении пустыми и конвейером не трогаются: считает их
// языковая модель отдельной задачей. Пишутся здесь ради симметрии с чтением —
// колонка, которую читают и не пишут, ничем не отличима от забытой.
record.Set("topics", r.TopicIDs)
}
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
@@ -78,8 +88,15 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
StructureID: nilIfEmpty(record.GetString("structure")),
RecognitionID: nilIfEmpty(record.GetString("recognition")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
OriginalFilename: nilIfEmpty(record.GetString("original_filename")),
// Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент
// помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а
// инвариант о колонках компилятор не проверяет.
DurationMs: numberValue(record.GetInt("duration_ms")),
SizeBytes: numberValue(record.GetInt("size_bytes")),
TopicIDs: record.GetStringSlice("topics"),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
@@ -103,6 +120,25 @@ func dateOrEmpty(v *time.Time) any {
return date
}
// numberOrZero отдаёт ноль вместо отсутствующего числа.
//
// «Неизвестно» числовая колонка хранилища не выражает вовсе: пустое значение она
// не держит и кладёт нулём. Отличимость потребовала бы четвёртой колонки-признака
// либо текстового типа у чисел, и платить за это нечем — обе величины ставит
// приём и ставит всегда. Решение владельца 2026-08-15.
func numberOrZero(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
// numberValue читает колонку числом. Ноль здесь означает ноль — см. numberOrZero.
func numberValue(value int) *int64 {
v := int64(value)
return &v
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
+52
View File
@@ -260,6 +260,39 @@ func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T
// Обратное направление того же правила: шаг, написанный под рубеж, которого в
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
// Отбор списка — очередной потребитель словаря рубежей, и перечислять их у него
// строкой запроса нельзя: рубеж, добавленный конвейером, молча поменял бы состав
// всех трёх состояний отбора, а заметить это было бы нечем.
//
// Правило смотрит, что выборка списка берёт рубежи у дескриптора, а не пишет их
// литералом. Инвариант проекта «Рубеж объявляется одним дескриптором» компилятор
// не проверяет — проверяет оно.
func TestОтборСпискаБерётРубежиУДескриптора(t *testing.T) {
const listFile = repoPkg + "/record_list.go"
body := readFile(t, listFile)
for _, value := range declaredStateValues(t) {
if strings.Contains(body, `"`+value+`"`) {
t.Errorf(
"отбор списка называет рубеж %q строкой: рубеж, добавленный "+
"дескриптором, молча не попадёт ни в одно состояние отбора",
value,
)
}
}
for _, fn := range []string{"entity.WorkingStages()", "entity.TerminalStages()"} {
if !strings.Contains(body, fn) {
t.Errorf(
"отбор списка не зовёт %s: перечень рубежей обязан приходить из "+
"дескриптора, а не собираться по месту",
fn,
)
}
}
}
func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
declared := map[string]bool{}
@@ -363,6 +396,25 @@ func stageDescriptor(t *testing.T) (all []string, working []string) {
}
// declaredStates — константы рубежей, объявленные доменом.
// declaredStateValues — **значения** рубежей, а не имена их констант: правило
// отбора ищет строковый литерал в чужом файле, и сравнивать его надо со
// значением.
//
// declaredStates рядом отдаёт имена констант — им пользуются правила, читающие
// код, а не строки.
func declaredStateValues(t *testing.T) []string {
t.Helper()
out := []string{}
re := regexp.MustCompile(`(?m)^\tState\w+\s*=\s*"([^"]+)"`)
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
out = append(out, m[1])
}
if len(out) == 0 {
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
}
return out
}
func declaredStates(t *testing.T) map[string]bool {
t.Helper()
out := map[string]bool{}
+37
View File
@@ -10,6 +10,43 @@ import (
// него не кладётся никогда.
var ErrOwnerRequired = errors.New("owner is required to accept a record")
// ErrRecordUnreadable — присланную запись не удалось прочитать: источник
// метаданных не разобрал её содержимое. Причина отказа — сама запись, а не сбой
// сервиса, и код ответа обязан называть причину, а не место.
//
// Заводится sentinel'ом, а не остаётся голой ошибкой источника метаданных:
// ветвь по умолчанию отдала бы `500`, и «файл негоден» читалось бы как «сломался
// сервер». Своих данных отказу нести нечего — имя файла в него не кладётся
// никогда.
var ErrRecordUnreadable = errors.New("uploaded record cannot be read")
// ErrRecordTooLarge — присланная запись длиннее потолка размера. Самый частый
// отказ у человека на мобильной сети, и прежде он уходил телом ограничителя тела
// — мимо единой формы отказа.
var ErrRecordTooLarge = errors.New("uploaded record exceeds size limit")
// ErrTextNotReady — текста запрошенного вида у записи ещё нет. Состояние, а не
// отсутствие: запись есть и принадлежит спрашивающему, просто конвейер до этого
// вида не дошёл. Отвечать на это тем же, чем отвечает чужая запись, нельзя —
// человек увидел бы «не найдено» на своей записи, загруженной минуту назад.
var ErrTextNotReady = errors.New("requested text view is not ready yet")
// ErrBadRequest — во входе запроса негодное значение: неизвестный вид текста,
// нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable
// тем, что негодна **просьба**, а не присланная запись.
var ErrBadRequest = errors.New("request input is not valid")
// ErrUnauthorized — сессии нет вовсе. Первая строка таблицы отображения, и без
// собственного признака она собиралась бы руками мимо единой точки: правка формы
// тела не доехала бы до неё, и два места разошлись бы молча.
var ErrUnauthorized = errors.New("session is required")
// ErrNotFound — под корнем приложения такого адреса нет. Отличается от
// JobNotFoundError тем, что не найдена **просьба**, а не запись: тело у ответа
// то же, но повод другой, и смешивать их в одном признаке значило бы называть
// отсутствующий адрес отсутствующей записью.
var ErrNotFound = errors.New("address not found")
type JobNotFoundError struct {
State string
Message string
+33
View File
@@ -71,8 +71,41 @@ type AcquiredRecord struct {
Holder string
}
// RecordCursor — положение в ленте записей, заданное **полным** ключом
// сортировки. Одного времени мало: у записей, принятых одним запросом, оно
// совпадает, и порядок между ними иначе не определён.
type RecordCursor struct {
CreatedAt string
ID string
}
// RecordQuery — что спрашивают у ленты записей.
type RecordQuery struct {
// OwnerID обязателен: пустой не совпадает ни с одной записью.
OwnerID string
// Filter — состояние записи. Пустой значит «все».
Filter *entity.ListFilter
// Cursor — положение, с которого продолжать. Пустой значит «сначала».
Cursor *RecordCursor
Limit int
}
// RecordPage — страница ленты. Ключ следующей страницы пуст, когда страница
// последняя.
type RecordPage struct {
Items []*entity.AudioRecord
NextCursor *RecordCursor
TotalItems int
}
type AudioRecordRepository interface {
Create(record *entity.AudioRecord) error
// List отдаёт страницу записей владельца, новыми сверху, не читая ни
// расшифровки, ни структуры реплик.
List(q RecordQuery) (*RecordPage, error)
// ResolveTopicNames разрешает темы названиями одним запросом на страницу и
// сужает их владельцем: словарь тем свой у каждого человека.
ResolveTopicNames(ownerID string, ids []string) (map[string]string, error)
// Save сохраняет запись, захват которой держит holder. Захват, доставшийся
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
// Пустой holder снимает эту условность и в конвейере не употребляется: все
+575
View File
@@ -0,0 +1,575 @@
package http
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service"
)
// AppRoot — корень адресов приложения.
//
// Приложение живёт своим пространством, а не в общем `/api/`: последнее
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
// нашим, и разойдутся они молча.
const AppRoot = "/app"
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив
// одним запросом и тем обойти постраничность её же параметром.
const (
DefaultPageLimit = 30
MaxPageLimit = 100
)
// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки.
//
// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает
// список, открывает карточку соседней записи и грузит новую, а бюджет
// ограничителя один на все адреса приложения и считается по адресу
// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят
// его пополам.
const pollBudgetShare = 8
// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку.
//
// Выводится из настройки ограничителя частоты под корнем приложения, а не
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
// которого сервис сам же ему обещал избежать.
//
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
// выполняется с запасом.
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
type AppHandler struct {
recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository
structureRepo contract.StructureRepository
trsService *service.TranscribeService
logger *slog.Logger
}
func NewAppHandler(
recordRepo contract.AudioRecordRepository,
textRepo contract.TextRepository,
structureRepo contract.StructureRepository,
trsService *service.TranscribeService,
logger *slog.Logger,
) *AppHandler {
if logger == nil {
logger = slog.Default()
}
return &AppHandler{
recordRepo: recordRepo,
textRepo: textRepo,
structureRepo: structureRepo,
trsService: trsService,
logger: logger,
}
}
// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы
// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о
// другую.
//
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
// Причина остановки — значение из закрытого перечня, и она не он: без причины
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном.
type RecordView struct {
ID string `json:"id"`
Title *string `json:"title"`
OriginalFilename *string `json:"original_filename"`
Brief *string `json:"brief"`
Topics []string `json:"topics"`
State string `json:"state"`
Halted bool `json:"halted"`
HaltReason *string `json:"halt_reason"`
DurationMs *int64 `json:"duration_ms"`
SizeBytes *int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
// AvailableViews — перечень доступных видов текста, а не признак «текст
// есть». Видов больше одного, и шаг завершения пишет их несколькими
// операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один
// признак отправил бы приложение за репликами, которых нет, и исход стал бы
// функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет».
//
// У элемента страницы поле опущено: страница видов не читает.
AvailableViews *[]string `json:"available_views,omitempty"`
}
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
//
// Место под признак заведено вперёд и заполняется другой задачей. Форма
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
// форму.
type IntakeItem struct {
RecordView
Duplicate bool `json:"duplicate"`
}
type PageView struct {
Items []RecordView `json:"items"`
NextCursor *string `json:"next_cursor"`
TotalItems int `json:"total_items"`
}
type MeView struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ConfigView struct {
MaxRecordSizeBytes int64 `json:"max_record_size_bytes"`
MaxPageSize int `json:"max_page_size"`
PollIntervalMs int64 `json:"poll_interval_ms"`
KnownExtensions []string `json:"known_extensions"`
MaxTopicsPerRecord int `json:"max_topics_per_record"`
}
type TextView struct {
View string `json:"view"`
Contents string `json:"contents,omitempty"`
Replicas []ReplicaView `json:"replicas,omitempty"`
}
type ReplicaView struct {
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
Text string `json:"text"`
}
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один.
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
app := r.Group(AppRoot)
// Слой сессии вешается на **группу корня**, а не на перечень адресов:
// перечень рос бы с каждым новым адресом приложения, и забытый в нём адрес
// молча перестал бы принимать куку. Собственная поверхность хранилища под
// слой не подпадает — часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
// иначе ушли бы телом библиотеки, мимо единой формы.
app.Bind(OneErrorForm())
app.Bind(SessionFromCookie())
app.Bind(RequireUser(migrations.UsersCollection))
app.GET("/me", h.Me)
app.GET("/config", h.Config)
// Приём стоит тем же адресом, что и список, и отличается только методом: он
// заводит аудиозапись, а не кладёт файл.
//
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Предел тела равен потолку
// самой записи, а отказ по нему уходит нашей формой.
app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize))
app.GET("/audiorecords", h.ListRecords)
app.GET("/audiorecords/{id}", h.GetRecord)
app.GET("/audiorecords/{id}/text", h.GetRecordText)
// Перехват «под нашим корнем такого адреса нет».
//
// Слой единой формы его не покрывает, и это не оплошность приоритета: отказ
// «ничего не совпало» рождается маршрутом **корневой** группы, к которому
// слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный
// путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа
// под корнем приложения было бы две.
//
// Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии
// отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса
// приложения, и по той же причине: сперва «кто спрашивает», потом «что».
app.Any("/{path...}", func(e *core.RequestEvent) error {
return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
})
}
func (h *AppHandler) Me(e *core.RequestEvent) error {
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
// человеку, а не сервису.
return e.JSON(http.StatusOK, MeView{
ID: e.Auth.Id,
Name: e.Auth.GetString("name"),
})
}
func (h *AppHandler) Config(e *core.RequestEvent) error {
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
// Приложение, знающее предел своей константой, расходится с сервером молча —
// до первого отказа на записи, которую человек уже успел отправить.
return e.JSON(http.StatusOK, ConfigView{
MaxRecordSizeBytes: entity.MaxRecordSize,
MaxPageSize: MaxPageLimit,
PollIntervalMs: PollIntervalMs,
KnownExtensions: metrics.PublicFormats(),
MaxTopicsPerRecord: entity.MaxTopicsPerRecord,
})
}
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
file, header, err := e.Request.FormFile("audio")
if err != nil {
// Предел тела ловит объявленную длину заранее, слоем; необъявленную —
// на чтении, уже здесь. Не различив эти два отказа, приём сказал бы
// человеку «вы не приложили файл» о записи, которую он приложил и
// которая просто больше потолка.
if errors.Is(err, apis.ErrRequestEntityTooLarge) {
return fail(e, contract.ErrRecordTooLarge)
}
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
}
defer func() {
if err := file.Close(); err != nil {
h.logger.Error("Failed to close uploaded file", "error", err)
}
}()
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// ли отправитель ответа: на контексте запроса приём терял бы полностью
// загруженную запись от одного обрыва соединения, а забрать результат он
// может и позже — карточкой записи. Значения контекста (журнал запроса,
// сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
// чужое имя.
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
// делает это одним местом — по причине отказа, а не по месту.
return fail(e, err)
}
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
return e.JSON(http.StatusCreated, []IntakeItem{{
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
// отсутствие поля и пустой перечень приложение не различит.
RecordView: h.viewOf(record, nil, &[]string{}),
}})
}
func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit}
if raw := e.Request.URL.Query().Get("limit"); raw != "" {
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
}
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
// сервис отдаёт, но просьба сама по себе не негодна.
q.Limit = min(limit, MaxPageLimit)
}
if raw := e.Request.URL.Query().Get("filter"); raw != "" {
filter, ok := entity.ParseListFilter(raw)
if !ok {
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
}
q.Filter = &filter
}
if raw := e.Request.URL.Query().Get("cursor"); raw != "" {
cursor, err := decodeCursor(raw)
if err != nil {
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
// архив, листающийся по кругу, и ни строки в журнале.
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
}
q.Cursor = cursor
}
page, err := h.recordRepo.List(q)
if err != nil {
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id)
return fail(e, err)
}
names, err := h.topicNames(e.Auth.Id, page.Items)
if err != nil {
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id)
return fail(e, err)
}
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
for _, record := range page.Items {
// Страница видов текста не читает: перечень доступных видов есть только у
// карточки, и опущенное поле честнее пустого — пустое читалось бы как
// «текста нет».
view.Items = append(view.Items, h.viewOf(record, names, nil))
}
if page.NextCursor != nil {
encoded := encodeCursor(page.NextCursor)
view.NextCursor = &encoded
}
return e.JSON(http.StatusOK, view)
}
func (h *AppHandler) GetRecord(e *core.RequestEvent) error {
record, err := h.readOwn(e)
if err != nil {
return fail(e, err)
}
names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record})
if err != nil {
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
return fail(e, err)
}
views := h.availableViews(record)
return e.JSON(http.StatusOK, h.viewOf(record, names, &views))
}
func (h *AppHandler) GetRecordText(e *core.RequestEvent) error {
view := e.Request.URL.Query().Get("view")
if !entity.IsKnownTextView(view) {
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
}
record, err := h.readOwn(e)
if err != nil {
return fail(e, err)
}
if view == entity.TextViewReplicas {
return h.replicasOf(e, record)
}
return h.plainTextOf(e, record, view)
}
// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают
// одним и тем же: по разнице ответов иначе перебирается список заведённых
// записей.
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) {
recordID := e.Request.PathValue("id")
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
// приходит отправителю как «вашей записи нет», а владелец сервиса об
// аварии не узнаёт ниоткуда.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
}
return nil, err
}
return record, nil
}
func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error {
textID := record.TranscriptTextID
if view == entity.TextViewLiterary {
textID = record.LiteraryTextID
}
if textID == nil {
return fail(e, contract.ErrTextNotReady)
}
text, err := h.textRepo.GetByID(*textID)
if err != nil {
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
return fail(e, err)
}
if text.Contents == "" {
return fail(e, contract.ErrTextNotReady)
}
return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents})
}
func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error {
if record.StructureID == nil {
return fail(e, contract.ErrTextNotReady)
}
structure, err := h.structureRepo.GetByID(*record.StructureID)
if err != nil {
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
return fail(e, err)
}
if len(structure.Replicas) == 0 {
return fail(e, contract.ErrTextNotReady)
}
replicas := make([]ReplicaView, 0, len(structure.Replicas))
for _, r := range structure.Replicas {
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text})
}
return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
}
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
// сотню записей иначе стоила бы сотни обращений к хранилищу.
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
seen := map[string]bool{}
ids := []string{}
for _, record := range records {
for _, id := range record.TopicIDs {
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
}
return h.recordRepo.ResolveTopicNames(ownerID, ids)
}
func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView {
topics := make([]string, 0, len(record.TopicIDs))
for _, id := range record.TopicIDs {
if name, ok := names[id]; ok {
topics = append(topics, name)
}
}
return RecordView{
ID: record.Id,
Title: record.Title,
OriginalFilename: record.OriginalFilename,
Brief: record.Brief,
Topics: topics,
State: record.State,
Halted: record.IsHalted(),
HaltReason: record.HaltReason,
DurationMs: record.DurationMs,
SizeBytes: record.SizeBytes,
CreatedAt: record.CreatedAt.Format(time.RFC3339),
AvailableViews: views,
}
}
// availableViews — какие виды текста у записи есть **сейчас**.
//
// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
// достижимо, потому что шаг завершения пишет их несколькими операциями.
//
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно:
// приложение опрашивало бы его без конца, а человек видел бы завершённую запись,
// из которой текст «вот-вот появится».
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
views := []string{}
if h.hasText(record.TranscriptTextID) {
views = append(views, entity.TextViewTranscript)
}
if h.hasText(record.LiteraryTextID) {
views = append(views, entity.TextViewLiterary)
}
if h.hasReplicas(record.StructureID) {
views = append(views, entity.TextViewReplicas)
}
return views
}
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса
// журналом, который пишет чтение текста.
func (h *AppHandler) hasText(textID *string) bool {
if textID == nil {
return false
}
text, err := h.textRepo.GetByID(*textID)
if err != nil {
h.logger.Error("Failed to read text while listing views", "error", err)
return false
}
return text.Contents != ""
}
func (h *AppHandler) hasReplicas(structureID *string) bool {
if structureID == nil {
return false
}
structure, err := h.structureRepo.GetByID(*structureID)
if err != nil {
h.logger.Error("Failed to read structure while listing views", "error", err)
return false
}
return len(structure.Replicas) > 0
}
// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
// составлять ключ руками значило бы завязаться на порядок сортировки.
//
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите —
// ключ уезжает параметром, а не телом.
func encodeCursor(c *contract.RecordCursor) string {
return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID))
}
func decodeCursor(raw string) (*contract.RecordCursor, error) {
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("cursor is not decodable: %w", err)
}
createdAt, id, ok := strings.Cut(string(decoded), "|")
if !ok || createdAt == "" || id == "" {
return nil, errors.New("malformed cursor")
}
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча
// обращает условие в постоянную истину или ложь — человек получает либо
// пустой архив при непустом счётчике, либо ленту с начала.
parsed, err := types.ParseDateTime(createdAt)
if err != nil || parsed.IsZero() {
return nil, errors.New("cursor carries no readable time")
}
return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil
}
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
// всех одна, а сказать человеку надо разное.
func errWithMessage(base error, message string) error {
return &messagedError{base: base, message: message}
}
type messagedError struct {
base error
message string
}
func (e *messagedError) Error() string { return e.message }
func (e *messagedError) Unwrap() error { return e.base }
+12
View File
@@ -246,6 +246,18 @@ func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (stri
return "", fmt.Errorf("failed to build exchange request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
// Адрес запросу нужен, хотя запрос внутрипроцессный и наружу не идёт.
//
// Ограничитель частоты хранилища ключует клиента адресом, а у собранного
// руками запроса его нет вовсе — и вырожденное значение библиотека отдаёт не
// пустой строкой, а литералом. Её собственный страж «пустой ключ пропускаем»
// такое значение не ловит, поэтому **все** внутренние обмены кода схлопнулись
// бы в один счётчик: третий вход в пределах трёх секунд — чей угодно —
// получал бы отказ ограничителя, неотличимый от настоящего отказа провайдера.
//
// Прежде этого не случалось: ограничитель был выключен целиком. Он включается
// вместе с правилом под корнем приложения, и цена названа здесь.
request.RemoteAddr = "127.0.0.1:0"
handler, err := h.storageHandler()
if err != nil {
+7 -7
View File
@@ -44,7 +44,7 @@ func TestApiRequiresSession(t *testing.T) {
})
t.Run("опрос готовности без сессии", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/status/anything", nil)
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
@@ -69,10 +69,10 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
require.Len(t, jobs, 1)
existing := httptest.NewRecorder()
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/api/status/"+jobs[0].Id, nil))
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil))
missing := httptest.NewRecorder()
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil))
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil))
assert.Equal(t, http.StatusUnauthorized, existing.Code)
assert.Equal(t, missing.Code, existing.Code)
@@ -123,7 +123,7 @@ func TestSessionSurvivesRestart(t *testing.T) {
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
@@ -164,7 +164,7 @@ func TestLogoutClosesAccess(t *testing.T) {
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
// себе не даёт: унесённое значение работало бы до истечения срока.
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
mux.ServeHTTP(afterResponse, after)
@@ -206,7 +206,7 @@ func TestLogoutWhenAccountIsGone(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
afterResponse := httptest.NewRecorder()
env.mux.ServeHTTP(afterResponse, after)
@@ -345,7 +345,7 @@ func TestCallbackRejectsForeignState(t *testing.T) {
func TestHeaderBeatsCookie(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
req.Header.Set("Authorization", env.session)
+427
View File
@@ -0,0 +1,427 @@
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)
}
+235
View File
@@ -0,0 +1,235 @@
package http
import (
"errors"
"net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
const (
CodeUnauthorized = "unauthorized"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeBadRequest = "bad_request"
CodeTooLarge = "too_large"
CodeNotReady = "not_ready"
CodeTooManyRequests = "too_many_requests"
CodeInternal = "internal"
)
// ErrorBody — единая форма тела отказа на всех адресах приложения.
//
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
// журнале владельца сервиса.
//
// Limit заполняется только у отказа по размеру: экран обязан показать предел
// числом, а не пересказать его словами.
type ErrorBody struct {
Code string `json:"error_code"`
Message string `json:"message"`
Limit *int64 `json:"limit,omitempty"`
}
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
// второе не говорило ему ничего.
//
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
// `internal`, и владелец сервиса видит в журнале аварию там, где её нет.
func mapDomainError(err error) (int, ErrorBody) {
switch {
case errors.Is(err, contract.ErrBadRequest):
// Причина у всех негодных вводов одна, а сказать человеку надо разное:
// «размер страницы отрицательный» и «неизвестный вид текста» ведут к
// разным действиям. Свой текст приезжает обёрткой; его нет — говорим
// общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы,
// а не библиотека.
message := "Запрос составлен неверно"
var owned *messagedError
if errors.As(err, &owned) {
message = owned.message
}
return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message}
case errors.Is(err, contract.ErrRecordUnreadable):
return http.StatusBadRequest, ErrorBody{
Code: CodeBadRequest,
Message: "Не удалось прочитать запись: формат не распознан или файл повреждён",
}
case errors.Is(err, contract.ErrRecordTooLarge):
limit := entity.MaxRecordSize
return http.StatusRequestEntityTooLarge, ErrorBody{
Code: CodeTooLarge,
Message: "Запись больше допустимого размера",
Limit: &limit,
}
case errors.Is(err, contract.ErrTextNotReady):
return http.StatusConflict, ErrorBody{
Code: CodeNotReady,
Message: "Текст этого вида для записи ещё не готов",
}
case errors.Is(err, contract.ErrNotFound):
message := "Адрес не найден"
var owned *messagedError
if errors.As(err, &owned) {
message = owned.message
}
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
case errors.Is(err, contract.ErrUnauthorized):
return http.StatusUnauthorized, ErrorBody{
Code: CodeUnauthorized,
Message: "Требуется вход",
}
case errors.Is(err, contract.ErrOwnerRequired):
return http.StatusForbidden, ErrorBody{
Code: CodeForbidden,
Message: "У вашей сессии нет учётной записи пользователя",
}
}
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
// ответов иначе перебирается список заведённых записей.
var notFound *contract.JobNotFoundError
if errors.As(err, &notFound) {
return http.StatusNotFound, ErrorBody{
Code: CodeNotFound,
Message: "Запись не найдена",
}
}
return http.StatusInternalServerError, ErrorBody{
Code: CodeInternal,
Message: "Внутренняя ошибка сервиса",
}
}
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
// уходит наружу с адресов приложения.
func fail(e *core.RequestEvent, err error) error {
status, body := mapDomainError(err)
return e.JSON(status, body)
}
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
//
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
//
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
// «внутренняя ошибка» вместо предела числом.
//
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
// него, включая предел тела и ограничитель частоты.
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberOneErrorForm",
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
Func: func(e *core.RequestEvent) error {
err := e.Next()
if err == nil {
return nil
}
// Обработчик, ответивший через fail, ошибки не возвращает — его
// форма уже ушла в ответ, и сюда доходит только чужая.
var apiErr *router.ApiError
if !errors.As(err, &apiErr) {
return err
}
status, translated := translateAPIError(apiErr)
return e.JSON(status, translated)
},
}
}
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
// названы поимённо: значение вне перечня приложению разбирать нечем.
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
switch apiErr.Status {
case http.StatusRequestEntityTooLarge:
limit := entity.MaxRecordSize
return http.StatusRequestEntityTooLarge, ErrorBody{
Code: CodeTooLarge,
Message: "Запись больше допустимого размера",
Limit: &limit,
}
case http.StatusTooManyRequests:
return http.StatusTooManyRequests, ErrorBody{
Code: CodeTooManyRequests,
Message: "Слишком много запросов подряд, попробуйте позже",
}
case http.StatusNotFound:
return http.StatusNotFound, ErrorBody{
Code: CodeNotFound,
Message: "Адрес не найден",
}
case http.StatusUnauthorized:
return mapDomainError(contract.ErrUnauthorized)
}
return apiErr.Status, ErrorBody{
Code: CodeInternal,
Message: "Внутренняя ошибка сервиса",
}
}
// RequireUser — слой предъявления адресов приложения.
//
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
// поимённо: без имени пускается всякая учётная запись хранилища, включая
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
// записи он стать не может.
//
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
// уложенный файл — чего сервис не умеет вовсе.
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberRequireUser",
// Сразу после слоя, который читает предъявленный токен: раньше него
// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
Func: func(e *core.RequestEvent) error {
if e.Auth == nil {
return fail(e, contract.ErrUnauthorized)
}
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
// предъявитель себя предъявил.
if e.Auth.Collection().Name != usersCollection {
return fail(e, contract.ErrOwnerRequired)
}
return e.Next()
},
}
}
+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(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
"название чужой темы наружу не выходит")
}
+7 -2
View File
@@ -182,13 +182,18 @@ func TestLoginCreatesAccountAndSession(t *testing.T) {
assert.True(t, cleared, "носитель состояния пережил возврат")
// Выданная сессия открывает доступ к закрытым адресам.
check := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
check := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
check.AddCookie(session)
checkResponse := httptest.NewRecorder()
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
NewTranscribeHandler(pbrepo.NewAudioRecordRepository(env.app), pbrepo.NewTextRepository(env.app), nil, nil).Register(r)
NewAppHandler(
pbrepo.NewAudioRecordRepository(env.app),
pbrepo.NewTextRepository(env.app),
pbrepo.NewStructureRepository(env.app),
nil, nil,
).Register(r)
checkMux, err := r.BuildMux()
require.NoError(t, err)
checkMux.ServeHTTP(checkResponse, check)
+6 -8
View File
@@ -63,10 +63,10 @@ func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
_, stranger := newSecondAccount(t, env.app)
foreign := httptest.NewRecorder()
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody))
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody))
unknown := httptest.NewRecorder()
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/api/status/unknown0000000000", http.NoBody))
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody))
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
@@ -86,10 +86,9 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
env.serve(w, createMultipartRequest(t, "sample.mp3", []byte("запись")))
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
response := intakeItemOf(t, w)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
@@ -113,10 +112,9 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
env.serve(w, req)
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
response := intakeItemOf(t, w)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.JobID)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
require.NoError(t, err)
assert.Equal(t, env.account.Id, record.GetString("owner"))
}
+58
View File
@@ -0,0 +1,58 @@
package http
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// Своё правило ограничителя частоты под корнем приложения.
//
// Заводится потому, что правило хранилища настроено на **его** корень и наших
// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с
// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая —
// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле.
//
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
// частота опроса, которую сервис объявляет приложению, — два числа об одном и
// том же разъехались бы при первой правке.
const (
appRateMaxRequests = 120
appRateWindowSec = 60
)
// ApplyAppRateLimit ставит правило ограничителя на корень приложения.
//
// Правило приводится к настройке при каждом подъёме, как и настройки провайдера:
// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и
// правило, положенное однажды, не пережило бы ни правки числа, ни чистого
// каталога данных.
func ApplyAppRateLimit(app core.App) error {
settings := app.Settings()
rule := core.RateLimitRule{
Label: AppRoot + "/",
MaxRequests: appRateMaxRequests,
Duration: appRateWindowSec,
}
replaced := false
for i, existing := range settings.RateLimits.Rules {
if existing.Label == rule.Label {
settings.RateLimits.Rules[i] = rule
replaced = true
break
}
}
if !replaced {
settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule)
}
settings.RateLimits.Enabled = true
if err := app.Save(settings); err != nil {
return fmt.Errorf("failed to apply app rate limit: %w", err)
}
return nil
}
+178 -44
View File
@@ -17,22 +17,105 @@ import (
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ об одной записи — то, ради чего эндпойнт и существует; ниже судятся его
// ветки: готовый текст, остановленная запись и отказ хранилища на чтении текста.
// Карточка записи и её текст читаются порознь: шестичасовая расшифровка,
// приехавшая вместе с шапкой, задерживает показ на мобильной сети на время,
// которое человеку не нужно ждать.
// statusOf спрашивает состояние записи от имени её владельца.
func statusOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
// cardOf спрашивает карточку записи от имени её владельца.
func cardOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/api/status/"+recordID, http.NoBody))
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID, http.NoBody))
return w
}
// Готовая расшифровка доезжает до отправителя полем `transcription_text`, и
// уходит в него **сырая** расшифровка: видов текста больше одного, и отдача
// «последнего записанного» сделала бы ответ функцией порядка записи.
func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
// textOf спрашивает текст записи названного вида.
func textOf(t *testing.T, env *testEnv, recordID, view string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID+"/text?view="+view, http.NoBody))
return w
}
// Карточка текста не несёт вовсе, а о его наличии сообщает перечнем доступных
// видов. Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
// достижимо, и один признак отправил бы приложение за репликами, которых нет.
func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
record.MoveToState(entity.StateDone)
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := cardOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var card RecordView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
assert.Equal(t, entity.StateDone, card.State)
assert.NotContains(t, w.Body.String(), "сырая расшифровка",
"текст в карточку не кладётся: за ним идут отдельным адресом")
require.NotNil(t, card.AvailableViews)
assert.Equal(t, []string{entity.TextViewTranscript}, *card.AvailableViews)
}
// Пока запись не дошла до текста, перечень доступных видов пуст. Пустой перечень
// значит «текста ещё нет» — и это состояние, а не отсутствие записи.
func TestRecordCard_NoTextYetGivesEmptyViews(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
w := cardOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var card RecordView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
assert.Equal(t, entity.StateUploaded, card.State)
assert.False(t, card.Halted, "запись в работе остановленной не значится")
require.NotNil(t, card.AvailableViews, "поле есть на проводе даже когда текста нет")
assert.Empty(t, *card.AvailableViews)
}
// Остановленная запись отдаёт рубеж, на котором встала, признак остановки и её
// причину. Этим держится инвариант проекта: опрос готовности убран, и карточка —
// единственное место, где отправитель узнаёт о неудаче.
func TestRecordCard_HaltedCarriesReason(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
record.MoveToState(entity.StateNormalized)
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := cardOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var card RecordView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
assert.Equal(t, entity.StateNormalized, card.State, "рубеж тот, на котором запись встала")
assert.True(t, card.Halted, "признак остановки виден владельцу записи")
require.NotNil(t, card.HaltReason, "без причины признак не говорит, чего ждать")
assert.Equal(t, entity.HaltReasonStepFailed, *card.HaltReason)
assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
"машинный текст отказа принадлежит журналу владельца сервиса, а не ответу")
}
// Сырая расшифровка отдаётся своим видом, и только она: видов текста больше
// одного, и отдача «последнего записанного» сделала бы ответ функцией порядка
// записи, а не состояния записи.
func TestRecordText_TranscriptView(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
@@ -45,61 +128,95 @@ func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
record.TranscriptTextID = &transcript.Id
record.LiteraryTextID = &literary.Id
record.MoveToState(entity.StateDone)
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := statusOf(t, env, record.Id)
w := textOf(t, env, record.Id, entity.TextViewTranscript)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
var text TextView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
assert.Equal(t, entity.StateDone, response.State)
require.NotNil(t, response.TranscriptionText, "готовый текст доехал до отправителя")
assert.Equal(t, "сырая расшифровка", *response.TranscriptionText)
assert.Equal(t, entity.TextViewTranscript, text.View)
assert.Equal(t, "сырая расшифровка", text.Contents)
assert.NotContains(t, w.Body.String(), "вычитанный текст",
"вычитанный текст этим полем не подменяется: значение поля не должно меняться от того, успел ли необязательный шаг")
"вычитанный текст этим видом не подменяется: у него своё значение перечня")
}
// Остановленная запись отдаёт рубеж, на котором встала, и признак остановки
// отдельным полем: отказ перестал быть состоянием, и без признака такая запись
// выглядела бы обычной, стоящей на своём рубеже.
func TestGetTranscribeJobStatus_HaltedIsVisible(t *testing.T) {
// Реплики со временем — своё значение перечня, а не форма показа расшифровки:
// они лежат структурой разбора и принадлежат записи, а не тексту.
func TestRecordText_ReplicasView(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
record.MoveToState(entity.StateNormalized)
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
structures := pbrepo.NewStructureRepository(env.app)
structure, err := structures.Put(record.Id, 1, []entity.Replica{
{StartMs: 0, EndMs: 1500, Text: "первая реплика"},
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
})
require.NoError(t, err)
record.StructureID = &structure.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := statusOf(t, env, record.Id)
w := textOf(t, env, record.Id, entity.TextViewReplicas)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
var text TextView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
assert.Equal(t, entity.StateNormalized, response.State, "рубеж тот, на котором запись встала")
assert.True(t, response.Halted, "признак остановки виден отправителю")
assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
"машинный текст отказа принадлежит журналу владельца, а не ответу отправителю")
assert.Equal(t, entity.TextViewReplicas, text.View)
require.Len(t, text.Replicas, 2)
assert.Equal(t, "первая реплика", text.Replicas[0].Text)
assert.Equal(t, int64(1500), text.Replicas[0].EndMs, "у каждой реплики стоит её время")
}
// Пока запись не дошла до текста, поля нет вовсе: пустая строка на его месте
// читается как «расшифровка пуста».
func TestGetTranscribeJobStatus_RunningRecordHasNoHaltedFlag(t *testing.T) {
// «Текста этого вида ещё нет» обязано отличаться от «записи нет»: иначе человек
// увидел бы «не найдено» на своей записи, загруженной минуту назад, — ровно тот
// отказ, ради устранения которого заведён весь контракт.
func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
w := statusOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
texts := pbrepo.NewTextRepository(env.app)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
// Расшифровка есть, структуры реплик нет — состояние достижимое: шаг
// завершения пишет их несколькими операциями.
notReady := textOf(t, env, record.Id, entity.TextViewReplicas)
assert.Equal(t, http.StatusConflict, notReady.Code)
assert.Equal(t, entity.StateUploaded, response.State)
assert.False(t, response.Halted, "запись в работе остановленной не значится")
assert.Nil(t, response.TranscriptionText)
var body ErrorBody
require.NoError(t, json.Unmarshal(notReady.Body.Bytes(), &body))
assert.Equal(t, CodeNotReady, body.Code)
missing := textOf(t, env, "nosuchrecordid", entity.TextViewTranscript)
assert.Equal(t, http.StatusNotFound, missing.Code)
var missingBody ErrorBody
require.NoError(t, json.Unmarshal(missing.Body.Bytes(), &missingBody))
assert.Equal(t, CodeNotFound, missingBody.Code)
assert.NotEqual(t, body.Code, missingBody.Code,
"«ещё не готово» и «записи нет» ведут к разным действиям человека")
}
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
// умолчание сделало бы ответ функцией того, что успел записать конвейер.
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
unknown := textOf(t, env, record.Id, "unknown-view")
assert.Equal(t, http.StatusBadRequest, unknown.Code)
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text", http.NoBody))
assert.Equal(t, http.StatusBadRequest, w.Code, "незаданный вид известным не считается")
}
// failingTextRepo отказывает на чтении текста — так выглядит недоступное
@@ -119,7 +236,7 @@ func (r *failingTextRepo) GetByID(string) (*entity.Text, error) {
// приходит своим кодом, и владелец сервиса узнаёт об аварии из журнала — иначе
// она читалась бы отправителю как «вашей записи не существует», а владельцем не
// замечалась бы вовсе.
func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
@@ -134,9 +251,10 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
// цепочка та же, что и в проде.
journal := &journalBuffer{}
handler := NewTranscribeHandler(
handler := NewAppHandler(
env.handler.recordRepo,
&failingTextRepo{},
pbrepo.NewStructureRepository(env.app),
env.handler.trsService,
slog.New(slog.NewTextHandler(journal, nil)),
)
@@ -147,7 +265,7 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
@@ -155,6 +273,22 @@ func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, w.Code,
"отказ хранилища не выдаётся за отсутствие записи")
assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят")
assert.Contains(t, journal.String(), "Failed to read transcript",
assert.Contains(t, journal.String(), "Failed to read text",
"владелец сервиса узнаёт об аварии из журнала")
}
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
func TestFormerAddressesAreGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
status := httptest.NewRecorder()
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
assert.Equal(t, http.StatusNotFound, status.Code)
intake := httptest.NewRecorder()
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
assert.Equal(t, http.StatusNotFound, intake.Code)
}
-169
View File
@@ -1,169 +0,0 @@
package http
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
)
type TranscribeHandler struct {
recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository
trsService *service.TranscribeService
logger *slog.Logger
}
func NewTranscribeHandler(
recordRepo contract.AudioRecordRepository,
textRepo contract.TextRepository,
trsService *service.TranscribeService,
logger *slog.Logger,
) *TranscribeHandler {
if logger == nil {
logger = slog.Default()
}
return &TranscribeHandler{recordRepo: recordRepo, textRepo: textRepo, trsService: trsService, logger: logger}
}
type CreateTranscribeJobResponse struct {
JobID string `json:"job_id"`
State string `json:"status"`
}
// GetTranscribeJobResponse — ответ об одной записи.
//
// Имена полей нормативны и остались прежними: контракт HTTP API объявлен
// проектом необратимым, и переименование поля ломает внешнюю программу молча.
// Изменились **значения** поля состояния — рубеж теперь называет достигнутое, — и
// это объявленная ломка.
//
// Поле `halted` новое: отказ перестал быть состоянием, и без него остановленная
// запись выглядела бы как обычная, стоящая на своём рубеже. Машинный текст
// отказа в ответ не идёт: он принадлежит журналу владельца сервиса.
type GetTranscribeJobResponse struct {
JobID string `json:"job_id"`
State string `json:"status"`
Halted bool `json:"halted"`
CreatedAt time.Time `json:"created_at"`
TranscriptionText *string `json:"transcription_text,omitempty"`
}
// Register вешает маршруты сервиса на роутер хранилища. Порт у сервиса и у
// панели один, поэтому и роутер один; имена полей ответа и коды при переезде
// сохранены — публичный контракт HTTP API объявлен необратимым.
func (h *TranscribeHandler) Register(r *router.Router[*core.RequestEvent]) {
api := r.Group("/api")
// Оба адреса уходят за аутентификацию. Слой предъявления стоит перед
// проверкой и действует только здесь: собственная поверхность хранилища под
// него не подпадает, часть её защищена ровно тем, что браузер заголовка сам
// не шлёт.
api.Bind(SessionFromCookie())
// Коллекция названа поимённо, а не оставлена умолчанию. Без имени проверка
// пускает всякую учётную запись хранилища, включая владельца панели, — а
// записи в коллекции пользователей у него нет, и владельцем записи он стать
// не может. Отказ такому предъявителю обязан наступить здесь, до чтения
// тела: позже пришлось бы убирать уже уложенный файл, а уборки файлов
// сервис не умеет вовсе.
api.Bind(apis.RequireAuth(migrations.UsersCollection))
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
// раньше обработчика, без строки в журнале приёма. Приём размеру не судья,
// поэтому предел тела равен потолку самой записи.
api.POST("/audio", h.CreateTranscribeJob).Bind(apis.BodyLimit(entity.MaxRecordSize))
api.GET("/status/{id}", h.GetTranscribeJobStatus)
}
func (h *TranscribeHandler) CreateTranscribeJob(e *core.RequestEvent) error {
// Получаем файл из формы
file, header, err := e.Request.FormFile("audio")
if err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "No audio file provided"})
}
defer func() {
if err := file.Close(); err != nil {
h.logger.Error("Failed to close uploaded file", "error", err)
}
}()
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// ли отправитель ответа: на контексте запроса приём терял бы полностью
// загруженную запись от одного обрыва соединения, а забрать результат он
// может и позже — по `GET /status/{id}`. Значения контекста (журнал запроса,
// сессия) при этом сохраняются, теряется только отмена.
ctx := context.WithoutCancel(e.Request.Context())
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
// чужое имя. Проверка предъявителя стоит слоем выше, поэтому здесь `e.Auth`
// уже есть и принадлежит коллекции пользователей.
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ.
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create transcibe job"})
}
// Возвращаем успешный ответ
return e.JSON(http.StatusCreated, CreateTranscribeJobResponse{
JobID: record.Id,
State: record.State,
})
}
func (h *TranscribeHandler) GetTranscribeJobStatus(e *core.RequestEvent) error {
recordID := e.Request.PathValue("id")
// Чужая запись, запись без владельца и несуществующая отвечают одним и тем
// же: хранилище отдаёт на все три ту же ошибку, а транспорт — тот же код и
// то же тело. Различать их наружу нельзя — по разнице ответов перебирается
// список заведённых записей.
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
// приходит отправителю как «вашей записи нет», а владелец сервиса об
// аварии не узнаёт ниоткуда.
var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) {
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
}
return e.JSON(http.StatusNotFound, map[string]string{"error": "Job not found"})
}
response := GetTranscribeJobResponse{
JobID: record.Id,
State: record.State,
Halted: record.IsHalted(),
CreatedAt: record.CreatedAt,
}
// Вид текста называется **явно**: видов у записи больше одного, и отдача
// «последнего записанного» сделала бы ответ функцией порядка записи, а не
// состояния записи. В это поле уходит сырая расшифровка, и только она.
if record.TranscriptTextID != nil {
text, err := h.textRepo.GetByID(*record.TranscriptTextID)
if err != nil {
h.logger.Error("Failed to read transcript", "error", err, "record_id", recordID)
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to read transcription"})
}
if text.Contents != "" {
contents := text.Contents
response.TranscriptionText = &contents
}
}
return e.JSON(http.StatusOK, response)
}
+76 -44
View File
@@ -69,7 +69,7 @@ func readableMetaViewer() *stubMetaViewer {
// рабочий каталог процесса проверки не трогают.
type testEnv struct {
mux http.Handler
handler *TranscribeHandler
handler *AppHandler
app core.App
journal *journalBuffer
// session — значение сессии вошедшего. Приём и опрос закрыты за
@@ -189,7 +189,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger,
)
handler := NewTranscribeHandler(recordRepo, textRepo, trsService, logger)
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
// Роутер собирается тем же способом, что и боевой: маршруты вешает сам
// обработчик, и проверка судит ту же цепочку, что и прод.
@@ -221,6 +221,16 @@ func createMultipartRequest(t *testing.T, fileName string, content []byte) *http
// createMultipartRequestWithField кладёт запись в поле с заданным именем —
// нужно, чтобы построить форму без поля `audio`.
func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request {
return createMultipartRequestAtWithField(t, "/app/audiorecords", field, fileName, content)
}
// createMultipartRequestAt собирает тот же запрос по названному адресу — нужно
// проверке, судящей убранные адреса.
func createMultipartRequestAt(t *testing.T, path, fileName string, content []byte) *http.Request {
return createMultipartRequestAtWithField(t, path, "audio", fileName, content)
}
func createMultipartRequestAtWithField(t *testing.T, path, field, fileName string, content []byte) *http.Request {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
@@ -233,12 +243,23 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
err = writer.Close()
require.NoError(t, err)
req := httptest.NewRequest("POST", "/api/audio", &buf)
req := httptest.NewRequest("POST", path, &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
return req
}
// intakeItemOf разбирает ответ приёма и отдаёт единственный его элемент. Ответ
// списком всегда, даже на один файл: форма согласована один раз и вперёд.
func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
t.Helper()
var items []IntakeItem
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &items))
require.Len(t, items, 1)
return items[0]
}
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFileNames(t *testing.T, env *testEnv) []string {
records, err := env.app.FindAllRecords(migrations.FilesCollection)
@@ -316,27 +337,32 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code)
// Имена полей ответа нормативны: контракт HTTP API объявлен необратимым.
// Судим по сырому JSON — разбор в CreateTranscribeJobResponse переименовал
// бы тег вместе с ожиданием, и проверка не смогла бы упасть.
var raw map[string]json.RawMessage
err := json.Unmarshal(w.Body.Bytes(), &raw)
// Имена полей ответа нормативны: контракт объявлен необратимым, а экраны
// строятся на этих именах. Судим по сырому JSON — разбор в структуру
// переименовал бы тег вместе с ожиданием, и проверка не смогла бы упасть.
//
// Ответ — **список**, даже когда файл в запросе один: форма согласована
// вперёд, чтобы приём нескольких файлов и распознавание повтора её не
// переписывали.
var rawItems []map[string]json.RawMessage
err := json.Unmarshal(w.Body.Bytes(), &rawItems)
require.NoError(t, err)
assert.Contains(t, raw, "job_id")
assert.Contains(t, raw, "status")
require.Len(t, rawItems, 1)
assert.Contains(t, rawItems[0], "id")
assert.Contains(t, rawItems[0], "state")
assert.Contains(t, rawItems[0], "duplicate", "место под признак повтора заведено вперёд")
assert.NotContains(t, rawItems[0], "job_id", "прежние имена полей убраны вместе с опросом")
var response CreateTranscribeJobResponse
err = json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
response := intakeItemOf(t, w)
assert.NotEmpty(t, response.JobID)
assert.NotEmpty(t, response.ID)
assert.Equal(t, entity.StateUploaded, response.State)
// Задача действительно заведена, а не только названа в ответе: иначе
// отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env))
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, job.State)
require.NotNil(t, job.OriginalFileID)
@@ -359,7 +385,7 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
req: func(t *testing.T) *http.Request {
// Запрос строится так, как его видит сервер: у пришедшего по
// проводу тело не бывает пустым указателем.
return httptest.NewRequest("POST", "/api/audio", http.NoBody)
return httptest.NewRequest("POST", "/app/audiorecords", http.NoBody)
},
},
{
@@ -379,11 +405,12 @@ func TestCreateTranscribeJob_NoFile(t *testing.T) {
require.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]string
var response ErrorBody
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "No audio file provided", response["error"])
assert.Equal(t, CodeBadRequest, response.Code)
assert.NotEmpty(t, response.Message, "рядом с кодом стоит фраза для человека")
assert.Equal(t, 0, countFiles(t, env))
assert.Equal(t, 0, countJobs(t, env))
})
@@ -402,11 +429,9 @@ func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
response := intakeItemOf(t, w)
assert.NotEmpty(t, response.JobID)
assert.NotEmpty(t, response.ID)
assert.Equal(t, entity.StateUploaded, response.State)
}
@@ -492,15 +517,19 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
// Негодная запись — отказ по причине, а не по месту: прежде здесь стоял
// `500`, и «файл не читается» приходило человеку как «сломался сервер».
require.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]string
var response ErrorBody
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Причина отказа принадлежит журналу, а не отправителю.
assert.Equal(t, "Failed to create transcibe job", response["error"])
assert.Equal(t, CodeBadRequest, response.Code)
assert.NotEmpty(t, response.Message)
assert.NotContains(t, w.Body.String(), "не удалось прочитать запись")
assert.NotContains(t, w.Body.String(), "transcibe", "опечатка ушла вместе с прежним текстом")
assert.Equal(t, 0, countJobs(t, env))
}
@@ -555,7 +584,7 @@ func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusInternalServerError, w.Code)
require.Equal(t, http.StatusBadRequest, w.Code)
journal := env.journal.String()
@@ -603,10 +632,9 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Code)
var response CreateTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
response := intakeItemOf(t, w)
job, err := env.handler.recordRepo.GetByID(response.JobID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
require.NoError(t, err)
require.NotNil(t, job.OriginalFileID)
@@ -690,19 +718,19 @@ func TestGetTranscribeJobStatus_Success(t *testing.T) {
job := jobWithFile(t, env)
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
var response RecordView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, job.Id, response.JobID)
assert.Equal(t, job.Id, response.ID)
assert.Equal(t, entity.StateUploaded, response.State)
assert.NotZero(t, response.CreatedAt)
assert.NotEmpty(t, response.CreatedAt)
}
func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
@@ -710,44 +738,48 @@ func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
job := jobWithFile(t, env)
req := httptest.NewRequest("GET", "/api/status/"+job.Id, http.NoBody)
req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusOK, w.Code)
// Судим по сырому JSON: пустая строка на месте отсутствующего текста
// читается клиентом как «расшифровка пуста», и разобранная структура
// эти два случая не различает.
// Судим по сырому JSON: имена полей карточки нормативны, а разбор в структуру
// переименовал бы тег вместе с ожиданием — и проверка не смогла бы упасть.
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
assert.Contains(t, raw, "job_id")
assert.Contains(t, raw, "status")
assert.Contains(t, raw, "id")
assert.Contains(t, raw, "state")
assert.Contains(t, raw, "created_at")
assert.NotContains(t, raw, "transcription_text")
assert.Contains(t, raw, "original_filename")
assert.Contains(t, raw, "duration_ms")
assert.Contains(t, raw, "size_bytes")
assert.NotContains(t, raw, "transcription_text", "текст читается своим адресом")
assert.NotContains(t, raw, "job_id", "прежние имена ушли вместе с опросом готовности")
}
func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
req := httptest.NewRequest("GET", "/api/status/non-existent-id", http.NoBody)
req := httptest.NewRequest("GET", "/app/audiorecords/non-existent-id", http.NoBody)
w := httptest.NewRecorder()
env.serve(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
var response map[string]string
var response ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, "Job not found", response["error"])
assert.Equal(t, CodeNotFound, response.Code, "код отказа машиночитаем")
assert.NotEmpty(t, response.Message, "и рядом с ним фраза для человека")
}
// Отправитель, у которого соединение оборвалось после полной загрузки, задачу
// всё равно получает: запись доехала целиком, а результат он заберёт позже по
// `GET /status/{id}`. Приём на контексте запроса терял бы такую запись молча —
// карточкой записи. Приём на контексте запроса терял бы такую запись молча —
// решение владельца от 2026-08-13.
func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
+63
View File
@@ -1,7 +1,9 @@
package entity
import (
"strings"
"time"
"unicode"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
@@ -62,6 +64,33 @@ type AudioRecord struct {
Title *string
Brief *string
// OriginalFilename — имя файла, данное отправителем. Лежит **отдельно от
// заголовка**: заголовок несёт название, которое дал человек либо посчитала
// языковая модель, а имя файла — то, по чему человек узнаёт свою запись, пока
// заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы
// имя, и вернуть затёртое было бы неоткуда.
//
// Значение приходит извне: приём режет его по MaxOriginalFilenameLen и убирает
// управляющие знаки. В имя файла хранилища и в журнал оно не идёт — инвариант
// приватности.
OriginalFilename *string
// DurationMs и SizeBytes — величины **принятого**, снимок с момента приёма.
// Со строкой файла они намеренно не сверяются: там лежат величины той копии,
// которой файл является сейчас, и уточнение длительности меняет их, не трогая
// эти. Нужны колонками записи, потому что показываются в списке.
//
// Указатели здесь не выражают «неизвестно»: числовая колонка хранилища
// пустого значения не держит, и пустое кладётся нулём. Обе величины ставит
// приём и ставит всегда — запись с непрочитанными метаданными отвергается
// отказом и не заводится вовсе. Решение владельца 2026-08-15.
DurationMs *int64
SizeBytes *int64
// TopicIDs — темы записи. Ни приём, ни конвейер их не пишут: место заведено
// вперёд, заполняет его задача, считающая темы языковой моделью.
TopicIDs []string
State string
// StateEnteredAt ставится только сменой рубежа и возвратом записи в работу.
// Откладывание опроса его не двигает — иначе застревание в чужой операции
@@ -99,6 +128,40 @@ type AudioRecord struct {
UpdatedAt time.Time
}
// MaxOriginalFilenameLen — потолок длины имени файла, данного отправителем.
//
// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому длина
// назначается сервисом. Число выведено из предела длины имени в распространённых
// файловых системах: имя длиннее 255 знаков не приходит от системного диалога
// выбора файла вовсе, и всё, что длиннее, — либо самодельный запрос, либо
// попытка раздуть строку записи.
const MaxOriginalFilenameLen = 255
// SanitizeOriginalFilename приводит имя, данное отправителем, к пригодному для
// хранения виду: убирает управляющие знаки и режет по потолку длины.
//
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
// иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки,
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
//
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
// по байтам разрубила бы знак пополам.
func SanitizeOriginalFilename(name string) string {
cleaned := strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, name)
runes := []rune(cleaned)
if len(runes) > MaxOriginalFilenameLen {
runes = runes[:MaxOriginalFilenameLen]
}
return string(runes)
}
// AllStates — закрытый перечень рубежей для схемы хранилища.
func AllStates() []string {
out := make([]string, 0, len(stages))
+69
View File
@@ -0,0 +1,69 @@
package entity
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// Имя файла приходит извне и содержимым своим приёму не подконтрольно. Правило
// чистки живёт в домене, а не в транспорте: имя доходит до колонки записи одним
// путём, и правил обязано быть одно.
func TestSanitizeOriginalFilename(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "обычное имя не трогается",
in: "разговор.mp3",
want: "разговор.mp3",
},
{
name: "управляющие знаки убираются",
in: "разго\x00вор\x07\x1b.mp3",
want: "разговор.mp3",
},
{
name: "перевод строки — тоже управляющий знак",
in: "первая\nвторая.mp3",
want: "перваявторая.mp3",
},
{
name: "пустое остаётся пустым",
in: "",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, SanitizeOriginalFilename(tc.in))
})
}
}
// Режется имя по знакам, а не по байтам: имя русское чаще, чем латинское, и
// обрезка по байтам разрубила бы знак пополам.
func TestSanitizeOriginalFilenameTrimsByRunes(t *testing.T) {
long := strings.Repeat("я", MaxOriginalFilenameLen+50)
got := SanitizeOriginalFilename(long)
assert.Len(t, []rune(got), MaxOriginalFilenameLen, "длина считается знаками")
assert.True(t, strings.HasPrefix(long, got), "обрезано с хвоста, а не переписано")
assert.Equal(t, long[:len(got)], got, "ни один знак не разрублен пополам")
}
// Уборка идёт до резки: иначе потолок съедали бы знаки, которых в сохранённом
// имени всё равно не будет.
func TestSanitizeOriginalFilenameCleansBeforeTrimming(t *testing.T) {
dirty := strings.Repeat("\x00", 100) + strings.Repeat("я", MaxOriginalFilenameLen)
got := SanitizeOriginalFilename(dirty)
assert.Len(t, []rune(got), MaxOriginalFilenameLen,
"сто управляющих знаков не откусили сто знаков имени")
}
+53
View File
@@ -76,6 +76,59 @@ func StageByName(name string) (Stage, bool) {
return Stage{}, false
}
// ListFilter — состояние записи, по которому её отбирает список приложения.
//
// Состояний три, а не два, и это не педантизм. Остановленная запись не в работе
// и не завершена: при отборе надвое она выпала бы из обеих половин — исчезла бы
// из списка при любом значении отбора, — хотя ради неё человек список и
// открывает.
type ListFilter string
const (
// ListFilterWorking — запись идёт по конвейеру.
ListFilterWorking ListFilter = "working"
// ListFilterHalted — запись остановлена признаком.
ListFilterHalted ListFilter = "halted"
// ListFilterDone — запись прошла конвейер.
ListFilterDone ListFilter = "done"
)
// ParseListFilter узнаёт состояние отбора по его имени. Второе значение ложно у
// имени, которого в перечне нет: такой отбор — негодный ввод, а не пустая
// выборка.
func ParseListFilter(v string) (ListFilter, bool) {
switch ListFilter(v) {
case ListFilterWorking, ListFilterHalted, ListFilterDone:
return ListFilter(v), true
}
return "", false
}
// TerminalStages — рубежи, из которых запись в работу не берут.
//
// Выводится из дескриптора наравне с WorkingStages: отбор списка — очередной
// потребитель словаря рубежей, и перечислять их у него строкой запроса нельзя.
// Рубеж, добавленный конвейером, иначе молча поменял бы состав всех трёх
// состояний отбора.
func TerminalStages() []Stage {
out := make([]Stage, 0, len(stages))
for _, s := range stages {
if s.Terminal {
out = append(out, s)
}
}
return out
}
// StageNames разворачивает рубежи в их имена — для запроса к хранилищу.
func StageNames(list []Stage) []string {
out := make([]string, 0, len(list))
for _, s := range list {
out = append(out, s.Name)
}
return out
}
// StuckLimits — пределы простоя, приходящие из настроек.
type StuckLimits struct {
// Own — предел на своей работе.
+30
View File
@@ -12,6 +12,36 @@ const (
TextKindLiterary = "literary"
)
// Виды текста, которыми приложение спрашивает текст записи.
//
// Перечень закрыт, и каждое значение называет ровно одну хранимую вещь. Назван
// он так, а не парой «вид текста плюс форма показа», потому что реплики со
// временем — не вид текста: они лежат структурой разбора и принадлежат записи, а
// не тексту. Пара из двух параметров обещала бы сочетания, которых не существует.
//
// Вычитанный текст назван здесь вперёд, хотя считает его отдельная задача:
// перечень без него пришлось бы расширять правкой публичного контракта — того
// самого, который согласован один раз.
const (
// TextViewTranscript — сырая расшифровка сплошным текстом.
TextViewTranscript = TextKindTranscript
// TextViewLiterary — вычитанный текст сплошным.
TextViewLiterary = TextKindLiterary
// TextViewReplicas — реплики со временем.
TextViewReplicas = "replicas"
)
// IsKnownTextView — принадлежит ли вид закрытому перечню. Незаданный вид
// известным не считается: умолчание сделало бы ответ функцией того, что успел
// записать конвейер, а не состояния записи.
func IsKnownTextView(view string) bool {
switch view {
case TextViewTranscript, TextViewLiterary, TextViewReplicas:
return true
}
return false
}
// AllTextKinds — закрытый перечень видов текста для схемы хранилища.
func AllTextKinds() []string {
return []string{TextKindTranscript, TextKindLiterary}
+28
View File
@@ -1,6 +1,7 @@
package metrics
import (
"slices"
"strconv"
"strings"
)
@@ -33,6 +34,33 @@ var knownFormats = map[string]struct{}{
"audio": {}, // умолчание сервиса, когда расширения в имени не было
}
// PublicFormats — тот же перечень, но для подсказки диалогу выбора файла в
// приложении: **без** собственного умолчания сервиса.
//
// Второй перечень рядом с первым разошёлся бы с ним молча, поэтому источник
// один. `audio` из него вычтено: это не формат, а умолчание на случай имени без
// расширения, и подсказкой человеку оно выходить не должно.
//
// Сервис по этому перечню **не судит**: приём о годности записи не решает сам —
// расширение он берёт из имени файла, а пригодность содержимого узнаёт у
// источника метаданных. Перечень служит диалогу выбора файла, не более; норму
// держит capability `archive`.
func PublicFormats() []string {
out := make([]string, 0, len(knownFormats))
for format := range knownFormats {
if format == defaultServiceFormat {
continue
}
out = append(out, format)
}
slices.Sort(out)
return out
}
// defaultServiceFormat — собственное умолчание сервиса на случай имени без
// расширения. Форматом не является.
const defaultServiceFormat = "audio"
// FormatLabel приводит расширение к виду, годному для метки метрики.
//
// Расширение приходит из имени, которое дал отправитель, и потому может быть
+8
View File
@@ -33,6 +33,14 @@ func (r *stubRecordRepo) Get(string) (*entity.AudioRecord, error) {
return nil, errors.New("не зовётся этими проверками")
}
func (r *stubRecordRepo) List(contract.RecordQuery) (*contract.RecordPage, error) {
return &contract.RecordPage{}, nil
}
func (r *stubRecordRepo) ResolveTopicNames(string, []string) (map[string]string, error) {
return map[string]string{}, nil
}
func (r *stubRecordRepo) FindAndAcquire([]entity.Stage) (*contract.AcquiredRecord, error) {
return nil, r.err
}
+40 -4
View File
@@ -22,6 +22,16 @@ import (
const (
defaultAudioExt = "audio"
// maxExtLen — потолок длины расширения вместе с точкой.
//
// Сторож от патологии, а не перечень допустимого: расширения известных
// форматов укладываются в пять знаков, и щедрый потолок ничего у отправителя
// не отнимает. Он нужен против другого — имени `x.` с четырьмястами знаками
// после точки: оно роняет заведение временного файла, и отправитель получает
// `500` на входе, за который отвечает сам, а владелец сервиса — строку `ERROR`,
// неотличимую от аварии хранилища.
maxExtLen = 32
// Предел отказов. Число обратимо и живёт здесь одним местом; счётчик растёт
// при захвате и обнуляется на шаге, завершившемся без отказа либо отложившем
// работу.
@@ -155,10 +165,15 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader
}
func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRecord, file io.Reader, fileName string) (*entity.AudioRecord, error) {
// Определяем расширение файла
// Расширение приходит из имени, которое дал отправитель, и потому может быть
// чем угодно. Длину назначает сервис: `filepath.Ext` режет по последней точке
// и всё, что после неё, берёт дословно, а имя `x.` плюс четыреста знаков
// роняет заведение временного файла — отправитель получал бы `500` на входе,
// за который отвечает сам, и клал бы в журнал владельца строку `ERROR`,
// неотличимую от аварии хранилища.
ext := filepath.Ext(fileName)
if ext == "" {
ext = fmt.Sprintf(".%s", defaultAudioExt) // fallback если расширение не определено
if ext == "" || len(ext) > maxExtLen {
ext = fmt.Sprintf(".%s", defaultAudioExt)
}
// Собственное имя записи: идентификатор с расширением. Имя, данное
@@ -184,7 +199,11 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
info, err := s.metaviewer.GetInfo(ctx, work.Path())
if err != nil {
s.logger.Error("Failed to get file info", "error", err, "file_ext", ext)
return nil, err
// Признак заводится здесь, а не остаётся голой ошибкой источника
// метаданных: причина отказа — присланная запись, а не сбой сервиса, и
// без признака ветвь по умолчанию отдала бы `500`. «Файл негоден»
// читалось бы как «сломался сервер», и человек не понял бы, что делать.
return nil, fmt.Errorf("%w: %w", contract.ErrRecordUnreadable, err)
}
size, err := work.Size()
@@ -217,6 +236,23 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
r.OriginalFileID = &fileRecord.Id
r.StateEnteredAt = clock.Now()
// Имя, данное отправителем, доходит до самой записи — по нему человек узнаёт
// её, пока заголовка нет. В имя файла хранилища и в журнал оно по-прежнему не
// идёт: инвариант приватности не тронут, сужена только область его действия.
//
// Колонка заголовка остаётся пустой: приём заголовков не сочиняет, а
// посчитанное языковой моделью название легло бы поверх имени, если бы они
// делили одну колонку.
if cleaned := entity.SanitizeOriginalFilename(fileName); cleaned != "" {
r.OriginalFilename = &cleaned
}
// Величины принятого — снимок с этой минуты. Со строкой файла они намеренно не
// сверяются: там лежат величины сегодняшней копии, и уточнение длительности
// меняет их, не трогая эти.
r.DurationMs = &meta.DurationMs
r.SizeBytes = &size
if err := s.repos.Records.Create(r); err != nil {
s.logger.Error("Failed to create audio record", "error", err, "file_id", fileRecord.Id)
return nil, err