package pocketbase import ( "strings" "testing" "github.com/google/uuid" "github.com/pocketbase/pocketbase/core" "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/clock" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Страж удаления учётной записи — единственное, что стоит между владельцем // панели и молчаливым обезличиванием чужого архива: при выключенном каскаде // хранилище снимает ссылку и сохраняет запись без проверок. func newAccount(t *testing.T, app core.App) *core.Record { t.Helper() users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) require.NoError(t, err) record := core.NewRecord(users) record.Set("email", uuid.NewString()+"@example.test") record.Set("verified", true) record.Set("password", uuid.NewString()) require.NoError(t, app.Save(record)) return record } // newRecordOf заводит аудиозапись названного владельца. func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord { t.Helper() record := &entity.AudioRecord{ State: entity.StateUploaded, StateEnteredAt: clock.Now(), Source: entity.SourceApi, OwnerID: ownerID, } require.NoError(t, NewAudioRecordRepository(app).Create(record)) return record } // Учётная запись с архивом не удаляется, и отказ называет причину — иначе // наружу приезжает подсказка библиотеки про обязательную связь, по которой // владелец панели пойдёт удалять записи руками. func TestGuardOwnerDeletion(t *testing.T) { app := newTestStorage(t) account := newAccount(t, app) record := newRecordOf(t, app, account.Id) err := app.Delete(account) require.Error(t, err, "учётная запись с архивом не удаляется") assert.Contains(t, err.Error(), "остались записи", "отказ называет причину") after, err := NewAudioRecordRepository(app).Get(record.Id) require.NoError(t, err, "запись на месте") assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний") } // Считаются все коллекции с владельцем, а не одни записи: файл переживает свою // запись, а тема живёт в словаре человека. func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) { cases := map[string]func(t *testing.T, app core.App, ownerID string){ "аудиозапись": func(t *testing.T, app core.App, ownerID string) { newRecordOf(t, app, ownerID) }, "один файл без записи": func(t *testing.T, app core.App, ownerID string) { repo := NewFileRepository(app) work, err := repo.Stage(".mp3", strings.NewReader("запись")) require.NoError(t, err) defer func() { require.NoError(t, work.Close()) }() _, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID) require.NoError(t, err) }, "одна тема словаря": func(t *testing.T, app core.App, ownerID string) { topics, err := app.FindCollectionByNameOrId(migrations.TopicsCollection) require.NoError(t, err) topic := core.NewRecord(topics) topic.Set("owner", ownerID) topic.Set("name", "личная тема") require.NoError(t, app.Save(topic)) }, } for name, own := range cases { t.Run(name, func(t *testing.T) { app := newTestStorage(t) account := newAccount(t, app) own(t, app, account.Id) err := app.Delete(account) require.Error(t, err, "учётная запись с этим добром не удаляется") assert.Contains(t, err.Error(), "остались записи", "отказ наш, а не подсказка библиотеки про обязательную связь") }) } } // Учётная запись, за которой ничего не числится, удаляется штатно: страж // заведён против потери архива, а не против удаления вообще. func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) { app := newTestStorage(t) account := newAccount(t, app) require.NoError(t, app.Delete(account), "пустая учётная запись удаляется") } // Колонка владельца пустого значения не принимает и умолчания не имеет: // ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни // конвейером, ни рукой в панели. func TestOwnerColumnRefusesEmptyValue(t *testing.T) { app := newTestStorage(t) for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} { t.Run(name, func(t *testing.T) { collection, err := app.FindCollectionByNameOrId(name) require.NoError(t, err) field := collection.Fields.GetByName("owner") require.NotNil(t, field, "колонка владельца заведена") relation, ok := field.(*core.RelationField) require.True(t, ok, "владелец — связь с учётной записью, а не строка") assert.True(t, relation.Required, "пустое значение колонка не принимает") assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом") }) } } // Та же норма со стороны сохранения: схема отвергает запись без владельца, а не // только объявляет колонку обязательной. func TestStorageRefusesRecordWithoutOwner(t *testing.T) { app := newTestStorage(t) record := &entity.AudioRecord{ State: entity.StateUploaded, StateEnteredAt: clock.Now(), Source: entity.SourceApi, } require.Error(t, NewAudioRecordRepository(app).Create(record), "ничья запись в хранилище не ложится") } // И файл — наравне с записью: разное правило у них читалось бы как недосмотр. func TestStorageRefusesFileWithoutOwner(t *testing.T) { app := newTestStorage(t) repo := NewFileRepository(app) work, err := repo.Stage(".mp3", strings.NewReader("запись")) require.NoError(t, err) defer func() { require.NoError(t, work.Close()) }() _, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "") require.Error(t, err, "ничей файл в хранилище не ложится") }