хранилище переехало с 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
+249 -95
View File
@@ -6,6 +6,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"mime/multipart"
"net/http"
@@ -15,20 +17,19 @@ import (
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
"git.vakhrushev.me/av/transcriber/internal/service"
)
@@ -66,19 +67,33 @@ func readableMetaViewer() *stubMetaViewer {
return &stubMetaViewer{seconds: 42}
}
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
// builtDist — собранное приложение, каким его видит раздача.
func builtDist() fs.FS {
return fstest.MapFS{
"index.html": {Data: []byte(testMarkup)},
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
"favicon.ico": {Data: []byte("значок")},
}
}
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
// рабочий каталог процесса проверки не трогают.
type testEnv struct {
mux http.Handler
handler *AppHandler
app core.App
db *sqliterepo.DB
store *sqliterepo.Store
users *sqliterepo.UserRepository
files *sqliterepo.FileRepository
journal *journalBuffer
// login — логин у провайдера, которым доверенный источник называет
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
// по существу, обязана назваться ровно так же, как это делает прокси.
login string
// account — учётная запись, которой принадлежит этот логин.
account *core.Record
account *contract.UserAccount
}
// Доверенный источник проверок.
@@ -93,10 +108,68 @@ const (
func testTrustedNetworks(t *testing.T) []netip.Prefix {
t.Helper()
prefix, err := netip.ParsePrefix("10.0.0.0/8")
// Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая
// для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую
// цепочку слоёв — у них адрес пира ставит ядро, и он петлевой.
networks := make([]netip.Prefix, 0, 2)
for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} {
prefix, err := netip.ParsePrefix(raw)
require.NoError(t, err)
networks = append(networks, prefix)
}
return networks
}
// liveServer поднимает настоящий HTTP-сервер на этой поверхности.
//
// Нужен там, где проверка судит поведение, которого у вызова обработчика
// напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а
// адрес пира ставит ядро.
func (e *testEnv) liveServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(e.mux)
t.Cleanup(server.Close)
return server
}
// liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и
// проверке остаётся судить значения.
type liveResponse struct {
StatusCode int
Header http.Header
ContentLength int64
Body []byte
}
// liveRequest шлёт настоящий запрос от имени учётной записи проверки.
func (e *testEnv) liveRequest(
t *testing.T, server *httptest.Server, path string, headers map[string]string,
) liveResponse {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil)
require.NoError(t, err)
req.Header.Set(LoginHeader, e.login)
for name, value := range headers {
req.Header.Set(name, value)
}
res, err := server.Client().Do(req)
require.NoError(t, err)
defer func() { require.NoError(t, res.Body.Close()) }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
return []netip.Prefix{prefix}
return liveResponse{
StatusCode: res.StatusCode,
Header: res.Header,
ContentLength: res.ContentLength,
Body: body,
}
}
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
@@ -113,30 +186,23 @@ func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
e.mux.ServeHTTP(w, asUser(req, e.login))
}
// newTestAccount заводит учётную запись с известным логином.
//
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо
// правил доступа так же, как идёт заведение записи первым обращением.
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
t.Helper()
// get шлёт запрос **неузнанным**: заголовка на нём нет вовсе.
func (e *testEnv) get(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w
}
const login = "person"
// getAs шлёт запрос от имени названного логина.
func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login))
return w
}
users, err := app.FindCollectionByNameOrId("users")
require.NoError(t, err)
record := core.NewRecord(users)
record.Set(migrations.ProviderLoginField, login)
record.Set("email", "person@example.com")
record.Set("verified", true)
// Случайный пароль ставит и сам сервис, когда заводит запись первым
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
// нему всё равно нельзя — парольный вход выключен шагом схемы.
record.SetRandomPassword()
require.NoError(t, app.Save(record))
return record, login
// getOwn шлёт запрос от имени учётной записи проверки.
func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder {
return e.getAs(e.login, path)
}
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
@@ -162,45 +228,72 @@ func (b *journalBuffer) String() string {
return b.text.String()
}
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
// ровно тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) core.App {
// newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно
// тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) {
t.Helper()
app, err := pbrepo.New(t.TempDir())
dir := t.TempDir()
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
if err := db.Close(); err != nil {
t.Logf("не удалось закрыть базу: %v", err)
}
})
return app
require.NoError(t, sqliterepo.Migrate(
context.Background(), db, dir, slog.New(slog.DiscardHandler),
))
return db, sqliterepo.NewStore(dir), dir
}
// envOptions — чем одна проверка отличается от другой.
type envOptions struct {
metaviewer contract.AudioMetaViewer
dist fs.FS
built bool
}
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
app := newTestStorage(t)
return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true})
}
pbrepo.BindPanelRules(app)
// setupEnv собирает поверхность сервиса тем же способом, что и точка входа:
// адреса порождает перечень адресного пространства, слои одевает та же цепочка.
// Собери проверка поверхность иначе — и судила бы она не то.
func setupEnv(t *testing.T, opts envOptions) *testEnv {
t.Helper()
db, store, _ := newTestStorage(t)
recordRepo := sqliterepo.NewAudioRecordRepository(db)
textRepo := sqliterepo.NewTextRepository(db)
structureRepo := sqliterepo.NewStructureRepository(db)
fileRepo := sqliterepo.NewFileRepository(db, store)
users := sqliterepo.NewUserRepository(db)
recordRepo := pbrepo.NewAudioRecordRepository(app)
textRepo := pbrepo.NewTextRepository(app)
repos := service.Repositories{
Records: recordRepo,
Files: pbrepo.NewFileRepository(app),
Files: fileRepo,
Texts: textRepo,
Structures: pbrepo.NewStructureRepository(app),
Recognitions: pbrepo.NewRecognitionRepository(app),
Events: pbrepo.NewRecordEventRepository(app),
Structures: structureRepo,
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
Events: sqliterepo.NewRecordEventRepository(db),
}
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
// отказа по-прежнему не попадают на экран и не размывают признак, по
// которому отличают новый красный шаг гейта от объявленного долга.
// отказа по-прежнему не попадают на экран.
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
metaviewer := opts.metaviewer
if metaviewer == nil {
metaviewer = readableMetaViewer()
}
trsService := service.NewTranscribeService(
repos,
metaviewer,
@@ -210,29 +303,31 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger,
)
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
// что и прод. Собери роутер иначе — и проверка судила бы не то.
r, err := apis.NewRouter(app)
mounts := ServiceMounts(
AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
http.NotFoundHandler(),
)
webapp := NewWebappHandler(opts.dist, opts.built, logger)
mux := BuildHandler(mounts, webapp, logger)
account, _, err := users.EnsureUser(contract.Identity{
Login: "person",
Name: "Человек",
Email: "person@example.com",
})
require.NoError(t, err)
mounts := ServiceMounts(handler, http.NotFoundHandler())
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
account, login := newTestAccount(t, app)
return &testEnv{
mux: mux,
handler: handler,
app: app,
db: db,
store: store,
users: users,
files: fileRepo,
journal: journal,
login: login,
login: "person",
account: account,
}
}
@@ -285,65 +380,115 @@ func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
return items[0]
}
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
// storedFileNames отдаёт имена, под которыми копии легли в каталог данных.
func storedFileNames(t *testing.T, env *testEnv) []string {
records, err := env.app.FindAllRecords(migrations.FilesCollection)
t.Helper()
rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files")
require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var names []string
for _, record := range records {
names = append(names, record.GetStringSlice("file")...)
for rows.Next() {
var name string
require.NoError(t, rows.Scan(&name))
names = append(names, name)
}
require.NoError(t, rows.Err())
return names
}
// countFiles считает записи о файлах.
// countRows считает строки названной таблицы.
func countRows(t *testing.T, env *testEnv, table string) int {
t.Helper()
var total int
require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total))
return total
}
// countFiles считает строки о копиях записей.
func countFiles(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
return len(records)
return countRows(t, env, "files")
}
// countJobs считает заведённые аудиозаписи.
func countJobs(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err)
return len(records)
return countRows(t, env, "audio_records")
}
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
// схемой, потому что без неё задача не пройдёт ни одного шага.
// jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл
// запись не прошла бы ни одного шага.
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
t.Helper()
repo := pbrepo.NewFileRepository(env.app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
return jobWithFileOf(t, env, env.account.ID)
}
// jobWithFileOf — то же, но от имени названного владельца.
func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord {
t.Helper()
recordID := ident.New()
work, err := env.files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
// владельца больше не заводится, и фикстура без него описывала бы состояние,
// которого в проде не бывает.
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
// Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и
// фикстура без владельца описывала бы состояние, которого в проде нет.
file, err := env.files.Create(
recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID,
)
require.NoError(t, err)
record := &entity.AudioRecord{
Id: recordID,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
OwnerID: env.account.Id,
OwnerID: ownerID,
OriginalFileID: &file.Id,
}
require.NoError(t, env.handler.recordRepo.Create(record))
return record
}
// storedContent читает содержимое файла из хранилища.
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
repo := pbrepo.NewFileRepository(env.app)
reader, err := repo.Open(fileID)
// newTopic заводит тему в словаре названного владельца.
//
// Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса
// нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана
// пройти тот же путь разрешения тем, каким пойдёт она.
func newTopic(t *testing.T, env *testEnv, ownerID, name string) string {
t.Helper()
id := ident.New()
now := clock.Now().UTC().Format("2006-01-02T15:04:05Z")
_, err := env.db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
id, ownerID, name, now, now,
)
require.NoError(t, err)
defer reader.Close()
return id
}
// attachTopic связывает тему с записью.
func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) {
t.Helper()
_, err := env.db.Writer().ExecContext(context.Background(),
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID,
)
require.NoError(t, err)
}
// storedContent читает содержимое копии из каталога данных.
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
t.Helper()
reader, err := env.files.Open(fileID)
require.NoError(t, err)
defer func() { require.NoError(t, reader.Close()) }()
var buf bytes.Buffer
_, err = buf.ReadFrom(reader)
@@ -387,7 +532,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
// отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env))
job, err := env.handler.recordRepo.GetByID(response.ID, 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)
@@ -529,9 +674,20 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
require.Len(t, names, 1)
assert.NotContains(t, names[0], "секретное-слово",
"имя, данное отправителем, в хранилище не попадает")
"имя, данное отправителем, в имя файла не попадает")
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
"расширение при этом сохраняется: %q", names[0])
// И в **путь** к файлу оно не попадает тоже: подкаталог назван
// идентификатором записи, а не именем отправителя.
var recordID, storedRecord string
require.NoError(t, env.db.Reader().
QueryRow("SELECT id FROM audio_records").Scan(&recordID))
require.NoError(t, env.db.Reader().
QueryRow("SELECT record_id FROM files").Scan(&storedRecord))
assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью")
assert.NotContains(t, storedRecord, "секретное-слово",
"имя, данное отправителем, попало в путь к файлу")
}
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
@@ -659,7 +815,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
response := intakeItemOf(t, w)
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
require.NoError(t, err)
require.NotNil(t, job.OriginalFileID)
@@ -821,7 +977,5 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя")
}