package sqlite import ( "context" "database/sql" "errors" "io" "io/fs" "log/slog" "os" "path/filepath" "strings" "sync" "syscall" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "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" ) // testSettings — числа базы под проверками: те же по смыслу, что и умолчания // конфига. func testSettings() Settings { return Settings{BusyTimeoutMs: 5000, ReadConnections: 4} } // newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же // путём, каким это делает сервис при старте. func newTestDB(t *testing.T) (*DB, *Store, string) { t.Helper() dir := t.TempDir() db, err := Open(dir, testSettings()) require.NoError(t, err) t.Cleanup(func() { if err := db.Close(); err != nil { t.Logf("не удалось закрыть базу: %v", err) } }) require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))) return db, NewStore(dir), dir } // newOwner заводит учётную запись и отдаёт её идентификатор. func newOwner(t *testing.T, db *DB) string { t.Helper() account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()}) require.NoError(t, err) return account.ID } // Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение // внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный // один раз после открытия, настроил бы одно соединение из многих. func TestSettingsApplyToEveryConnection(t *testing.T) { db, _, _ := newTestDB(t) var mode string require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode)) assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен") var busy int require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy)) assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера") // Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем // **несколько** разом: одно настроенное соединение из четырёх — ровно та // поломка, ради которой настройка уехала в строку подключения. var wg sync.WaitGroup answers := make([]int, testSettings().ReadConnections) start := make(chan struct{}) for i := range answers { wg.Add(1) go func() { defer wg.Done() <-start conn, err := db.Reader().Conn(context.Background()) if !assert.NoError(t, err) { return } defer func() { assert.NoError(t, conn.Close()) }() assert.NoError(t, conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i])) // Соединение придерживается, пока спрашивают остальные: иначе пул // раздал бы всем одно и то же и правило проверило бы одну настройку // вместо четырёх. time.Sleep(10 * time.Millisecond) }() } close(start) wg.Wait() for i, answer := range answers { assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i) } // И держатся внешние ключи **на деле**, а не только настройкой: вставка с // несуществующим владельцем отвергается обоими пулами. now := clock.Now().Format(timeLayout) insert := `INSERT INTO audio_records (id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at) VALUES (?, ?, 0, 0, ?, ?, ?, ?)` _, err := db.Writer().ExecContext(context.Background(), insert, ident.New(), ident.New(), entity.StateUploaded, now, now, now) require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем") _, err = db.Reader().ExecContext(context.Background(), insert, ident.New(), ident.New(), entity.StateUploaded, now, now, now) require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем") } // Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим. func TestSettingsAreValidated(t *testing.T) { for name, settings := range map[string]Settings{ "нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4}, "нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0}, "отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1}, "отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4}, } { t.Run(name, func(t *testing.T) { _, err := Open(t.TempDir(), settings) assert.Error(t, err, "старт на негодном числе прошёл молча") }) } } // Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних // записей не теряет. func TestMigrateIsIdempotent(t *testing.T) { dir := t.TempDir() db, err := Open(dir, testSettings()) require.NoError(t, err) defer func() { require.NoError(t, db.Close()) }() require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))) owner := newOwner(t, db) require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))) var login string require.NoError(t, db.Reader(). QueryRowContext(context.Background(), "SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login)) assert.NotEmpty(t, login, "повторный накат потерял прежние строки") var applied int require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied)) assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен") } // Накат держится исключающей блокировкой каталога данных: второй накат ждёт // освобождения, а не применяет шаги параллельно. // // Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели // объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит // именно его. func TestMigrationLockSerializesRuns(t *testing.T) { dir := t.TempDir() var ( mu sync.Mutex inside int overlap bool ) hold := func() error { mu.Lock() inside++ if inside > 1 { overlap = true } mu.Unlock() time.Sleep(50 * time.Millisecond) mu.Lock() inside-- mu.Unlock() return nil } var wg sync.WaitGroup for range 3 { wg.Add(1) go func() { defer wg.Done() assert.NoError(t, withMigrationLock(dir, hold)) }() } wg.Wait() assert.False(t, overlap, "два наката шли одновременно: замок не держит") } // Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой // схеме, отвечал бы отказом на каждый запрос. func TestMigrateFailsLoudly(t *testing.T) { dir := t.TempDir() db, err := Open(dir, testSettings()) require.NoError(t, err) defer func() { require.NoError(t, db.Close()) }() // Таблица уже занята чужой строкой: начальный шаг на такой базе не // применяется. _, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)") require.NoError(t, err) err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)) require.Error(t, err, "отказ шага прошёл молча") assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг") // **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не // оставляет за собой ни отметки, ни половины схемы. Полуприменённое // состояние — то самое, из-за которого следующий запуск применил бы шаг // второй раз и упал бы на заведённой таблице. var version int err = db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version) if err == nil { assert.Equal(t, 0, version, "отказавший шаг отмечен применённым") } var tables int require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables)) assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы") } // Все колонки времени объявлены одним типом и без умолчания: умолчание схемы // писало бы свой вид времени, а вставка, забывшая проставить время, при нём // прошла бы молча. func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) { db, _, _ := newTestDB(t) tables := []string{ "users", "files", "topics", "audio_records", "texts", "structures", "recognitions", "record_events", } seen := 0 for _, table := range tables { rows, err := db.Reader().QueryContext(context.Background(), "SELECT name, type, dflt_value FROM pragma_table_info(?)", table) require.NoError(t, err) for rows.Next() { var ( name string columnType string dflt any ) require.NoError(t, rows.Scan(&name, &columnType, &dflt)) if !isTimeColumn(name) { continue } seen++ assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name) assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name) } require.NoError(t, rows.Err()) closeRows(t, rows) } require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет") } // closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в // цикле по таблицам: отложенное закрытие копилось бы до конца проверки. func closeRows(t *testing.T, rows *sql.Rows) { t.Helper() require.NoError(t, rows.Close()) } func isTimeColumn(name string) bool { return strings.HasSuffix(name, "_at") || name == "delay_time" } // Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в // отбор захвата одинаково: вид времени в схеме один. func TestHandwrittenRecordIsAcquiredToo(t *testing.T) { db, _, _ := newTestDB(t) owner := newOwner(t, db) records := NewAudioRecordRepository(db) byService := &entity.AudioRecord{ Id: ident.New(), OwnerID: owner, State: entity.StateUploaded, StateEnteredAt: clock.Now(), } require.NoError(t, records.Create(byService)) byHand := ident.New() now := clock.Now().Format(timeLayout) _, err := db.Writer().ExecContext(context.Background(), `INSERT INTO audio_records (id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at) VALUES (?, ?, 0, 0, ?, ?, ?, ?)`, byHand, owner, entity.StateUploaded, now, now, now, ) require.NoError(t, err) acquired := map[string]bool{} for range 2 { got, err := records.FindAndAcquire(entity.WorkingStages()) require.NoError(t, err) acquired[got.ID] = true } assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась") assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась") } // Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей // не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей. func TestHotQueriesUseIndexes(t *testing.T) { db, _, _ := newTestDB(t) acquire := explain(t, db, ` SELECT id FROM audio_records WHERE state IN (?, ?) AND halted_at IS NULL AND (delay_time IS NULL OR delay_time < ?) AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?) ORDER BY created_at, id LIMIT 1`, entity.StateUploaded, entity.StateNormalized, "now", "now") list := explain(t, db, ` SELECT id FROM audio_records WHERE owner_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT 31`, "owner", "now", "now", "id") for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} { assert.NotContains(t, plan, "SCAN audio_records", "%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan) assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan) assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan) } } func explain(t *testing.T, db *DB, query string, args ...any) string { t.Helper() rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...) require.NoError(t, err) defer func() { require.NoError(t, rows.Close()) }() var plan strings.Builder for rows.Next() { var id, parent, notUsed int var detail string require.NoError(t, rows.Scan(&id, &parent, ¬Used, &detail)) plan.WriteString(detail) plan.WriteString("; ") } require.NoError(t, rows.Err()) return plan.String() } // Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не // даёт паники. func TestCloseIsIdempotent(t *testing.T) { db, err := Open(t.TempDir(), testSettings()) require.NoError(t, err) require.NoError(t, db.Close()) assert.NoError(t, db.Close(), "повторное закрытие отказало") } // Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни // файла под рабочим именем, ни временного имени в подкаталоге записи. func TestStorePutIsAtomic(t *testing.T) { _, store, dir := newTestDB(t) recordID := ident.New() _, err := store.Put(recordID, "voice.mp3", &brokenReader{}) require.Error(t, err, "отказ источника прошёл молча") _, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3")) assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке") temporary, err := store.HasTemporary(recordID) require.NoError(t, err) assert.False(t, temporary, "временное имя осталось в подкаталоге записи") } // brokenReader отдаёт часть потока и обрывается — так выглядит отправитель, // закрывший соединение на середине. type brokenReader struct { sent bool } func (r *brokenReader) Read(p []byte) (int, error) { if !r.sent { r.sent = true copy(p, strings.Repeat("a", min(len(p), 64))) return min(len(p), 64), nil } return 0, errors.New("источник оборвался") } // Отказ укладки несёт причину и не несёт пути. // // Обе половины — одно требование, и порознь они друг друга отменяют. Причина // нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка // каталога требуют трёх разных действий, а отказ укладки — единственная // поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога // данных, а отказ кончается в журнале, откуда строку потом не убрать. func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) { _, store, dir := newTestDB(t) // noPath судит вторую половину: ни каталога данных, ни временной приставки // в цепочке отказа быть не должно. noPath := func(t *testing.T, err error) { t.Helper() require.Error(t, err) assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ") assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ") } t.Run("места на диске нет", func(t *testing.T) { recordID := ident.New() _, err := store.Put(recordID, "voice.mp3", &diskFullReader{ path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"), }) noPath(t, err) assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего") }) t.Run("прав на подкаталог записи нет", func(t *testing.T) { recordID := ident.New() recordDir := filepath.Join(dir, recordsDir, recordID) require.NoError(t, os.MkdirAll(recordDir, 0o750)) require.NoError(t, os.Chmod(recordDir, 0o500)) t.Cleanup(func() { if err := os.Chmod(recordDir, 0o750); err != nil { t.Logf("не удалось вернуть права подкаталогу записи: %v", err) } }) _, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные")) noPath(t, err) assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего") }) t.Run("подкаталогом записи занято не то", func(t *testing.T) { recordID := ident.New() require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750)) require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600)) _, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные")) noPath(t, err) assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего") }) t.Run("копии нет", func(t *testing.T) { _, err := store.Open(ident.New(), "voice.mp3") noPath(t, err) assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего") }) } // diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой // пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File` // рабочей копии, и его отказ приходит ровно этой формой. type diskFullReader struct { path string } func (r *diskFullReader) Read([]byte) (int, error) { return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC} } // Копии одной записи лежат вместе — под её идентификатором, — и второго места, // где лежит что-то из них, нет. func TestCopiesOfRecordLiveTogether(t *testing.T) { db, store, dir := newTestDB(t) owner := newOwner(t, db) files := NewFileRepository(db, store) recordID := ident.New() for _, name := range []string{"original.mp3", "normalized.ogg"} { work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name)) require.NoError(t, err) _, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner) require.NoError(t, err) require.NoError(t, work.Close()) } entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID)) require.NoError(t, err) names := make([]string, 0, len(entries)) for _, entry := range entries { names = append(names, entry.Name()) } assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names) records, err := os.ReadDir(filepath.Join(dir, recordsDir)) require.NoError(t, err) assert.Len(t, records, 1, "второго места для копий записи не появляется") } // Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а // не файл целиком. func TestOpenGivesSeekableStream(t *testing.T) { db, store, _ := newTestDB(t) owner := newOwner(t, db) files := NewFileRepository(db, store) recordID := ident.New() work, err := files.Stage(".mp3", strings.NewReader("0123456789")) require.NoError(t, err) file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner) require.NoError(t, err) require.NoError(t, work.Close()) reader, err := files.Open(file.Id) require.NoError(t, err) defer func() { require.NoError(t, reader.Close()) }() _, err = reader.Seek(4, io.SeekStart) require.NoError(t, err) slice := make([]byte, 3) _, err = io.ReadFull(reader, slice) require.NoError(t, err) assert.Equal(t, "456", string(slice)) }