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

- адреса приложения переехали в своё пространство `/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
+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())