- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
555 lines
22 KiB
Go
555 lines
22 KiB
Go
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))
|
|
}
|