у записи появился владелец: чужую больше не отдают

- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
This commit is contained in:
av
2026-08-14 12:18:11 +03:00
parent b7d4660aef
commit 8af8ec2e54
46 changed files with 2520 additions and 101 deletions
+7
View File
@@ -43,6 +43,13 @@ func New(dataDir string) (*pb.PocketBase, error) {
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
}
// Страж владельца вешается здесь, а не вызывающим: он защищает архив от
// удаления учётной записи, и сборка, забывшая его позвать, теряет защиту
// молча. Так это уже и было — окружение проверок его не ставило, и всё
// разграничение проверялось на приложении, где архив сносится одним
// запросом.
GuardOwnerDeletion(app)
return app, nil
}
@@ -116,7 +116,7 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
// приватности этого не допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile, ownerID string) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
@@ -132,6 +132,11 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
record.Set("file", stored)
record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size)
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
// бы доступным всякому вошедшему.
record.Set("owner", ownerID)
if err := repo.app.Save(record); err != nil {
// Отказ укладки называет имя файла — то самое, из которого строится
@@ -143,7 +148,7 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e
return recordToFile(record), nil
}
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
func (repo *FileRepository) CreateRemote(objectKey string, size int64, ownerID string) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
@@ -153,6 +158,7 @@ func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.
record.Set("location", entity.LocationS3)
record.Set("object_key", objectKey)
record.Set("size", size)
record.Set("owner", ownerID)
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store remote file record: %w", err)
@@ -31,7 +31,7 @@ func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
require.NoError(t, err)
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
file, err := repo.CreateLocal("big.mp3", work)
file, err := repo.CreateLocal("big.mp3", work, "")
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
assert.Equal(t, size, file.Size)
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
@@ -39,6 +39,10 @@ func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
// поля здесь не с кем.
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
applyOwnedByPipeline(record, job)
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
// записанный поверх, стёр бы его молча.
record.Set("owner", derefString(job.OwnerID))
record.Set("source", job.Source)
record.Set("tg_chat_id", derefInt64(job.TgChatId))
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
@@ -48,6 +52,7 @@ func recordToJob(record *core.Record) *entity.TranscribeJob {
return &entity.TranscribeJob{
Id: record.Id,
State: record.GetString("state"),
OwnerID: nilIfEmpty(record.GetString("owner")),
Source: record.GetString("source"),
FileID: nilIfEmpty(record.GetString("file")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
@@ -69,8 +74,13 @@ func recordToJob(record *core.Record) *entity.TranscribeJob {
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
// целиком.
type acquiredRow struct {
Id string `db:"id"`
State string `db:"state"`
Id string `db:"id"`
State string `db:"state"`
// Владелец конвейеру не нужен для выборки — она им не сужается, — но
// читается: снимок задачи, в котором владелец всегда пуст, был бы ловушкой
// для первого же шага, начавшего сохранять задачу целиком. Сегодня это ещё
// и несущее чтение: шаг конвейера кладёт владельца на файл, который заводит.
OwnerID sql.NullString `db:"owner"`
Source string `db:"source"`
FileID sql.NullString `db:"file"`
ErrorText sql.NullString `db:"error_text"`
@@ -90,6 +100,7 @@ func (r *acquiredRow) toJob() *entity.TranscribeJob {
job := &entity.TranscribeJob{
Id: r.Id,
State: r.State,
OwnerID: nullToPtr(r.OwnerID),
Source: r.Source,
FileID: nullToPtr(r.FileID),
ErrorText: nullToPtr(r.ErrorText),
@@ -0,0 +1,106 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// up202608140001 заводит владельца записи.
//
// Колонка — связь с коллекцией пользователей: хранилище само следит, чтобы
// владельцем стояла существующая учётная запись, а не строка, похожая на её
// идентификатор.
//
// Пустое значение допустимо, и это решение с названной ценой. Записи, принятые
// ботом, владельца не имеют вовсе: связи чата Telegram с учётной записью сервис
// не ведёт, её заводит отдельная задача. Обязательность для приёма по HTTP
// держит поэтому сам приём, а не схема.
//
// Каскадное удаление выключено, но одного этого мало: при выключенном каскаде
// хранилище **вынимает** идентификатор из поля связи и сохраняет запись без
// проверок, то есть архив удалённого пользователя стал бы ничьим и не достался
// бы никому. Поэтому удаление учётной записи, у которой остались задачи,
// отвергается слоем приложения — `GuardOwnerDeletion`.
func up202608140001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
if err != nil {
return fmt.Errorf("failed to find jobs collection: %w", err)
}
jobs.Fields.Add(ownerField(users.Id))
if err := app.Save(jobs); err != nil {
return fmt.Errorf("failed to add owner to jobs: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
files.Fields.Add(ownerField(users.Id))
// Правило просмотра сужается владельцем. Прежнее пускало всякого узнанного:
// владельца у записи тогда не было, и сужать выборку было нечем. Без этой
// строки разграничение закрыло бы метаданные задачи и оставило открытым
// содержимое — то самое, что оно и заведено прятать: знание идентификатора
// файловой записи равнялось бы праву скачать чужое аудио.
files.ViewRule = ptr(`@request.auth.id != "" && owner = @request.auth.id`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to narrow files by owner: %w", err)
}
return nil
}
// ownerField собирает описание колонки владельца. Обе коллекции получают
// одинаковую: разойдясь, они дали бы разное поведение у задачи и у её файла.
func ownerField(usersCollectionID string) *core.RelationField {
return &core.RelationField{
Name: "owner",
CollectionId: usersCollectionID,
MaxSelect: 1,
// Пустое значение допустимо — см. шапку шага. Умолчания у колонки нет:
// связь его не имеет по устройству, и запись не достаётся никому по
// недосмотру схемы.
Required: false,
// Удаление учётной записи не уносит её записи следом: сервис объявлен
// архивом. Что происходит вместо этого, держит `GuardOwnerDeletion`.
CascadeDelete: false,
}
}
// down202608140001 снимает колонку с обеих коллекций и возвращает правило
// просмотра файлов к тому, что стояло до шага, — «всякий узнанный».
func down202608140001(app core.App) error {
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", name, err)
}
field := collection.Fields.GetByName("owner")
if field == nil {
return errors.New("collection " + name + " has no owner field")
}
collection.Fields.RemoveById(field.GetId())
if name == FilesCollection {
collection.ViewRule = ptr(`@request.auth.id != ""`)
}
if err := app.Save(collection); err != nil {
return fmt.Errorf("failed to drop owner from %s: %w", name, err)
}
}
return nil
}
@@ -24,6 +24,10 @@ import (
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
// на приёме: строковый литерал в двух местах разошёлся бы молча.
UsersCollection = "users"
)
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
@@ -31,6 +35,7 @@ const (
func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
}
func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,80 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
// задачи расшифровки.
//
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи
// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API
// никому — архив человека исчез бы молча и восстановлению не подлежал:
// прежнего владельца не остаётся нигде.
//
// Цена запрета названа прямо: владелец панели упирается в отказ, а способа
// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До
// неё удаление учётной записи с записями невозможно, и это осознанный тупик.
//
// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами
// суперпользователя, и правило коллекции её не судит. Удаление при этом не
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
// учётную запись запросом, так что страж закрывает и публичную поверхность.
//
// Считаются **обе** коллекции с владельцем. Файл переживает свою задачу: шаг
// конвейера заводит его до сохранения задачи, и потерянный захват оставляет файл
// с владельцем и без ссылки. Учётная запись, у которой остались одни такие
// файлы, без этого счёта удалялась бы штатно, а аудио становилось бы ничьим.
func GuardOwnerDeletion(app core.App) {
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
count, err := countOwned(e.App, e.Record.Id)
if err != nil {
return err
}
if count > 0 {
// Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает
// наружу только `*router.ApiError`, а всякую другую подменяет своим
// сообщением — «убедитесь, что запись не участвует в обязательной
// связи». Подсказка эта не просто бесполезная, а **ведущая**:
// единственная обязательная связь у задачи — файл, и владелец панели,
// поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает
// ровно то необратимое, ради предотвращения чего страж и заведён.
//
// Число в отказе — не содержимое записей, а их счёт: он говорит
// владельцу панели, почему удаление не прошло, и не выносит наружу
// ничего о самих записях.
return router.NewBadRequestError(fmt.Sprintf(
"у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими",
count,
), nil)
}
return e.Next()
})
}
// countOwned считает всё, что принадлежит учётной записи, — по обеим коллекциям
// с колонкой владельца. Перечень живёт здесь одним списком: разойдясь с шагом
// схемы, он оставил бы половину архива без защиты молча.
func countOwned(app core.App, ownerID string) (int64, error) {
var total int64
for _, collection := range []string{migrations.JobsCollection, migrations.FilesCollection} {
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
if err != nil {
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
}
total += count
}
return total, nil
}
@@ -0,0 +1,182 @@
package pocketbase
import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// newAccount заводит учётную запись и отдаёт её идентификатор.
func newAccount(t *testing.T, app core.App, email string) string {
t.Helper()
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", email)
record.Set("verified", true)
record.SetRandomPassword()
require.NoError(t, app.Save(record))
return record.Id
}
// Колонка владельца заводится шагом схемы на чистой базе, и умолчания у неё нет.
func TestOwnerColumnHasNoDefault(t *testing.T) {
app := newTestApp(t)
for _, name := range []string{migrations.JobsCollection, migrations.FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
field := collection.Fields.GetByName("owner")
require.NotNil(t, field, "колонка владельца заведена в %s", name)
relation, ok := field.(*core.RelationField)
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит записи")
// Умолчания у связи нет по устройству: запись, чей владелец не назван,
// не достаётся никому по недосмотру схемы.
record := core.NewRecord(collection)
assert.Empty(t, record.GetString("owner"), "новая запись приходит без владельца")
}
}
// Чужая задача, ничья и несуществующая дают одну и ту же ошибку.
func TestGetByID_NarrowedByOwner(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
mine := newAccount(t, app, "mine@example.com")
stranger := newAccount(t, app, "stranger@example.com")
owned := newJob(t, repo, entity.StateCreated)
owned.OwnerID = &mine
require.NoError(t, repo.Save(owned, ""))
// Владельца кладёт заведение, а не сохранение конвейера, — ставим его прямо.
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
require.NoError(t, err)
record.Set("owner", mine)
require.NoError(t, app.Save(record))
ownerless := newJob(t, repo, entity.StateCreated)
got, err := repo.GetByID(owned.Id, mine)
require.NoError(t, err, "своя задача отдаётся")
assert.Equal(t, owned.Id, got.Id)
_, foreignErr := repo.GetByID(owned.Id, stranger)
_, ownerlessErr := repo.GetByID(ownerless.Id, mine)
_, missingErr := repo.GetByID("nonexistent0000", mine)
var notFound *contract.JobNotFoundError
require.ErrorAs(t, foreignErr, &notFound, "чужая задача не отдаётся")
require.ErrorAs(t, ownerlessErr, &notFound, "ничья задача не отдаётся")
require.ErrorAs(t, missingErr, &notFound, "несуществующая тоже")
}
// Пустой владелец не совпадает ни с чем: ни со своей задачей, ни с чужой, ни с
// ничьей. Правило записано со стороны спрашивающего — обязательность, которую
// держит одна лишь подпись метода, пустую строку пропускает.
func TestGetByID_EmptyOwnerMatchesNothing(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
owner := newAccount(t, app, "mine@example.com")
owned := newJob(t, repo, entity.StateCreated)
record, err := app.FindRecordById(migrations.JobsCollection, owned.Id)
require.NoError(t, err)
record.Set("owner", owner)
require.NoError(t, app.Save(record))
ownerless := newJob(t, repo, entity.StateCreated)
var notFound *contract.JobNotFoundError
for _, id := range []string{owned.Id, ownerless.Id, "nonexistent0000"} {
_, err := repo.GetByID(id, "")
require.ErrorAs(t, err, &notFound, "пустой владелец не открывает %s", id)
}
}
// Удаление учётной записи с задачами отвергается: связь с выключенным каскадом
// иначе снимает ссылку, и архив человека становится ничьим и недостижимым.
func TestGuardOwnerDeletion(t *testing.T) {
// Страж вешает сама сборка хранилища — звать его отдельно не нужно и нельзя:
// второй вызов повесил бы второй слой.
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
withJobs := newAccount(t, app, "keeper@example.com")
empty := newAccount(t, app, "empty@example.com")
job := newJob(t, repo, entity.StateCreated)
record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
record.Set("owner", withJobs)
require.NoError(t, app.Save(record))
keeper, err := app.FindRecordById(migrations.UsersCollection, withJobs)
require.NoError(t, err)
err = app.Delete(keeper)
require.Error(t, err, "учётная запись с задачами не удаляется")
// Отказ обязан быть ошибкой роутера, а не обычной: наружу библиотека
// пропускает только её, а всякую другую подменяет своим сообщением про
// обязательную связь — подсказкой, по которой владелец панели пойдёт удалять
// задачи и файлы руками. Проверяется поэтому тип и текст, а не сам факт
// отказа: на потерянном сообщении факт остаётся прежним.
var apiErr *router.ApiError
require.ErrorAs(t, err, &apiErr, "отказ доезжает до владельца панели")
assert.Contains(t, apiErr.Message, "остались записи",
"причина названа, а не подменена библиотечной")
// Задача и её владелец остались прежними.
after, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err)
assert.Equal(t, withJobs, after.GetString("owner"), "владелец не снят")
free, err := app.FindRecordById(migrations.UsersCollection, empty)
require.NoError(t, err)
assert.NoError(t, app.Delete(free), "учётная запись без записей удаляется")
}
// Файл переживает свою задачу: шаг конвейера заводит его до сохранения задачи, и
// потерянный захват оставляет файл с владельцем и без ссылки. Считать одни
// задачи значило бы отдать такое аудио на молчаливое обезличивание.
func TestGuardOwnerDeletion_CountsFilesToo(t *testing.T) {
app := newTestApp(t)
repo := NewFileRepository(app)
owner := newAccount(t, app, "files@example.com")
work, err := repo.Stage(".ogg", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = repo.CreateLocal("sample.ogg", work, owner)
require.NoError(t, err)
record, err := app.FindRecordById(migrations.UsersCollection, owner)
require.NoError(t, err)
err = app.Delete(record)
require.Error(t, err, "учётная запись с одними файлами тоже не удаляется")
after, err := app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
require.Len(t, after, 1)
assert.Equal(t, owner, after[0].GetString("owner"), "владелец файла не снят")
}
@@ -80,17 +80,49 @@ func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder stri
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
// GetByID отдаёт задачу, только если её владелец — ownerID.
//
// Чужая задача, задача без владельца и несуществующая дают одну и ту же ошибку:
// по разнице ответов иначе перебирается список заведённых задач, а
// идентификатор задачи и есть то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
// вызывающий без учётной записи получил бы ровно множество задач без владельца,
// то есть все записи бота.
//
// Владелец сверяется тем же чтением, каким берётся состояние, а не отдельным
// запросом: между двумя чтениями задача успевает измениться, и ответ перестаёт
// быть функцией от того, что с ней произошло.
func (repo *TranscriptJobRepository) GetByID(id, ownerID string) (*entity.TranscribeJob, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "job not found"}
}
record, err := repo.app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
// «Такой записи нет» переводится в доменную ошибку **здесь**, у
// источника, как велит конвенция об ошибках. Иначе три исхода, которые
// разграничение обязано сделать неразличимыми, разъезжаются: чужая и
// ничья задачи дают доменную ошибку, а несуществующая — отказ базы,
// неотличимый от настоящей аварии хранилища. Держалась бы эта
// неразличимость только тем, что транспорт кладёт в `404` любую ошибку,
// — то есть ровно тем расхождением, которое конвенция велит закрыть.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "job not found"}
}
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
if record.GetString("owner") != ownerID {
return nil, &contract.JobNotFoundError{Message: "job not found"}
}
return recordToJob(record), nil
}
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
// отдал бы и порядок, зависящий от схемы.
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
const acquireColumns = `id, state, owner, source, file, error_text, acquisition_id, ` +
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
`tg_chat_id, tg_reply_message_id, created, updated`
@@ -45,7 +45,7 @@ func newFile(t *testing.T, app core.App) *entity.File {
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
file, err := repo.CreateLocal("sample.mp3", work, "")
require.NoError(t, err)
return file
}
@@ -246,7 +246,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
require.ErrorAs(t, err, &lost)
// И состояние не поехало.
after, err := repo.GetByID(mine.Id)
after, err := readJobByID(t, app, mine.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
}
@@ -261,7 +261,7 @@ func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
require.NoError(t, repo.Save(job, ""))
after, err := repo.GetByID(job.Id)
after, err := readJobByID(t, app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State)
}
@@ -291,7 +291,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
// запросом к записи, а не сохранением из кода.
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
after, err := repo.GetByID(job.Id)
after, err := readJobByID(t, app, job.Id)
require.NoError(t, err)
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
assert.Nil(t, after.AcquireTime, "время захвата снято")
@@ -323,7 +323,7 @@ func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
after, err := readJobByID(t, app, job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
@@ -333,7 +333,7 @@ func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
after.Die("attempts exhausted: 6")
require.NoError(t, repo.Save(after, ""))
dead, err := repo.GetByID(job.Id)
dead, err := readJobByID(t, app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, dead.State)
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
@@ -404,9 +404,22 @@ func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
acquired.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
after, err := readJobByID(t, app, job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
require.NotNil(t, after.TgChatId)
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
}
// readJobByID читает задачу мимо сужения владельцем: проверки хранилища смотрят
// задачи без владельца, а читающий метод их не отдаёт никому. Отображение при
// этом то же самое — сверяется именно оно.
func readJobByID(t *testing.T, app core.App, id string) (*entity.TranscribeJob, error) {
t.Helper()
record, err := app.FindRecordById(migrations.JobsCollection, id)
if err != nil {
return nil, err
}
return recordToJob(record), nil
}