приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -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, ¬Found) {
|
||||
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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ¬Found) {
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
|
||||
"название чужой темы наружу не выходит")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ¬Found) {
|
||||
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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user