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, ¬Found, "чужая задача не отдаётся") require.ErrorAs(t, ownerlessErr, ¬Found, "ничья задача не отдаётся") require.ErrorAs(t, missingErr, ¬Found, "несуществующая тоже") } // Пустой владелец не совпадает ни с чем: ни со своей задачей, ни с чужой, ни с // ничьей. Правило записано со стороны спрашивающего — обязательность, которую // держит одна лишь подпись метода, пустую строку пропускает. 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, ¬Found, "пустой владелец не открывает %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"), "владелец файла не снят") }