Files
transcriber/internal/controller/http/status_test.go
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

296 lines
14 KiB
Go

package http
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/pocketbase/pocketbase/apis"
"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/entity"
)
// Карточка записи и её текст читаются порознь: шестичасовая расшифровка,
// приехавшая вместе с шапкой, задерживает показ на мобильной сети на время,
// которое человеку не нужно ждать.
// cardOf спрашивает карточку записи от имени её владельца.
func cardOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID, http.NoBody))
return w
}
// 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)
texts := pbrepo.NewTextRepository(env.app)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
record.LiteraryTextID = &literary.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := textOf(t, env, record.Id, entity.TextViewTranscript)
require.Equal(t, http.StatusOK, w.Code)
var text TextView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
assert.Equal(t, entity.TextViewTranscript, text.View)
assert.Equal(t, "сырая расшифровка", text.Contents)
assert.NotContains(t, w.Body.String(), "вычитанный текст",
"вычитанный текст этим видом не подменяется: у него своё значение перечня")
}
// Реплики со временем — своё значение перечня, а не форма показа расшифровки:
// они лежат структурой разбора и принадлежат записи, а не тексту.
func TestRecordText_ReplicasView(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
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 := textOf(t, env, record.Id, entity.TextViewReplicas)
require.Equal(t, http.StatusOK, w.Code)
var text TextView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))
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 TestRecordText_NotReadyDiffersFromNotFound(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
require.NoError(t, env.handler.recordRepo.Save(record, ""))
// Расшифровка есть, структуры реплик нет — состояние достижимое: шаг
// завершения пишет их несколькими операциями.
notReady := textOf(t, env, record.Id, entity.TextViewReplicas)
assert.Equal(t, http.StatusConflict, notReady.Code)
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 отказывает на чтении текста — так выглядит недоступное
// хранилище. Битую ссылку схема завести не даёт (связь проверяется при
// сохранении), и это её защита, а не пробел: остаётся отказ самого чтения.
type failingTextRepo struct{}
func (r *failingTextRepo) Put(string, string, string) (*entity.Text, error) {
return nil, errors.New("не зовётся этой проверкой")
}
func (r *failingTextRepo) GetByID(string) (*entity.Text, error) {
return nil, errors.New("хранилище недоступно")
}
// Отказ чтения текста — это отказ хранилища, а не «записи нет». Отправителю он
// приходит своим кодом, и владелец сервиса узнаёт об аварии из журнала — иначе
// она читалась бы отправителю как «вашей записи не существует», а владельцем не
// замечалась бы вовсе.
func TestRecordText_ReadFailureIsNotANotFound(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
require.NoError(t, env.handler.recordRepo.Save(record, ""))
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
// цепочка та же, что и в проде.
journal := &journalBuffer{}
handler := NewAppHandler(
env.handler.recordRepo,
&failingTextRepo{},
pbrepo.NewStructureRepository(env.app),
env.handler.trsService,
slog.New(slog.NewTextHandler(journal, nil)),
)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil))
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
asUser(req, env.login)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code,
"отказ хранилища не выдаётся за отсутствие записи")
assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят")
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)
}