хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+83 -18
View File
@@ -8,12 +8,9 @@ import (
"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"
)
@@ -47,7 +44,7 @@ func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) {
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
@@ -120,7 +117,7 @@ func TestRecordText_TranscriptView(t *testing.T) {
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
@@ -149,7 +146,7 @@ func TestRecordText_ReplicasView(t *testing.T) {
record := jobWithFile(t, env)
structures := pbrepo.NewStructureRepository(env.app)
structures := env.handler.structureRepo
structure, err := structures.Put(record.Id, 1, []entity.Replica{
{StartMs: 0, EndMs: 1500, Text: "первая реплика"},
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
@@ -179,7 +176,7 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
@@ -204,6 +201,64 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
"«ещё не готово» и «записи нет» ведут к разным действиям человека")
}
// Каждая ветвь «ещё не готово» отвечает одним кодом и одним телом.
//
// Ветвей четыре, и достижимы все: текста нет вовсе, текст заведён пустым,
// структуры реплик нет, структура заведена без единой реплики. Шаг завершения
// пишет результат несколькими операциями, поэтому запись законно застаётся между
// ними. Ветвь, отвечающая иначе, отправила бы приложение за текстом, которого
// нет, либо показала бы «записи нет» на своей записи.
func TestRecordText_EveryNotReadyBranchIsOneAnswer(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
notReady := func(t *testing.T, record *entity.AudioRecord, view string) {
t.Helper()
w := textOf(t, env, record.Id, view)
require.Equal(t, http.StatusConflict, w.Code, view)
var body ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, CodeNotReady, body.Code, view)
assert.NotEmpty(t, body.Message, view)
}
t.Run("текста нет вовсе", func(t *testing.T) {
record := jobWithFile(t, env)
notReady(t, record, entity.TextViewTranscript)
notReady(t, record, entity.TextViewLiterary)
notReady(t, record, entity.TextViewReplicas)
})
t.Run("текст заведён пустым", func(t *testing.T) {
record := jobWithFile(t, env)
transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "")
require.NoError(t, err)
record.LiteraryTextID = &literary.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
notReady(t, record, entity.TextViewTranscript)
notReady(t, record, entity.TextViewLiterary)
})
t.Run("структура заведена без единой реплики", func(t *testing.T) {
record := jobWithFile(t, env)
structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{})
require.NoError(t, err)
record.StructureID = &structure.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
notReady(t, record, entity.TextViewReplicas)
})
}
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
// умолчание сделало бы ответ функцией того, что успел записать конвейер.
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
@@ -241,7 +296,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
@@ -251,20 +306,21 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
// цепочка та же, что и в проде.
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
handler := NewAppHandler(
env.handler.recordRepo,
&failingTextRepo{},
pbrepo.NewStructureRepository(env.app),
env.handler.structureRepo,
env.handler.fileRepo,
env.handler.trsService,
slog.New(slog.NewTextHandler(journal, nil)),
logger,
)
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)
mounts := ServiceMounts(
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger),
http.NotFoundHandler(),
)
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
asUser(req, env.login)
@@ -280,16 +336,25 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
//
// Отвечают они теперь тем же, чем отвечает всякий путь вне корней сервиса, —
// разметкой приложения: корень `/api` перестал существовать вместе со
// встроенным хранилищем, и резервировать имя за отказом сервис не берётся.
func TestFormerAddressesAreGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
before := countJobs(t, env)
status := httptest.NewRecorder()
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
assert.Equal(t, http.StatusNotFound, status.Code)
assert.Equal(t, http.StatusOK, status.Code)
assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку")
assert.NotContains(t, status.Body.String(), entity.StateUploaded,
"прежний адрес отдал данные записи")
intake := httptest.NewRecorder()
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
assert.Equal(t, http.StatusNotFound, intake.Code)
assert.Equal(t, http.StatusMethodNotAllowed, intake.Code)
assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись")
}