хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// newWorkingRecord заводит запись, пригодную к захвату.
|
||||
func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := &entity.AudioRecord{
|
||||
Id: ident.New(),
|
||||
OwnerID: owner,
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
}
|
||||
require.NoError(t, NewAudioRecordRepository(db).Create(record))
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена
|
||||
// связью с учётной записью и пустого значения не принимает.
|
||||
func TestRecordWithoutOwnerIsRejected(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
t.Run("пустой владелец", func(t *testing.T) {
|
||||
err := records.Create(&entity.AudioRecord{
|
||||
Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(),
|
||||
})
|
||||
assert.Error(t, err, "запись с пустым владельцем сохранилась")
|
||||
})
|
||||
|
||||
t.Run("владельца нет среди учётных записей", func(t *testing.T) {
|
||||
err := records.Create(&entity.AudioRecord{
|
||||
Id: ident.New(), OwnerID: ident.New(),
|
||||
State: entity.StateUploaded, StateEnteredAt: clock.Now(),
|
||||
})
|
||||
assert.Error(t, err, "запись с выдуманным владельцем сохранилась")
|
||||
})
|
||||
|
||||
t.Run("запросом к базе тоже", func(t *testing.T) {
|
||||
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, ?, ?, ?, ?)`,
|
||||
ident.New(), entity.StateUploaded, now, now, now,
|
||||
)
|
||||
assert.Error(t, err, "ничья запись завелась запросом к базе")
|
||||
})
|
||||
}
|
||||
|
||||
// Файл без владельца не сохраняется — тем же правилом схемы.
|
||||
func TestFileWithoutOwnerIsRejected(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
files := NewFileRepository(db, store)
|
||||
|
||||
work, err := files.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "")
|
||||
assert.Error(t, err, "файл с пустым владельцем сохранился")
|
||||
}
|
||||
|
||||
// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется:
|
||||
// запрет держит схема обязательной связью, а не проверка вызывающего.
|
||||
func TestAccountWithBelongingsIsNotDeletable(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
|
||||
t.Run("с аудиозаписями", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
newWorkingRecord(t, db, owner)
|
||||
|
||||
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.Error(t, err, "учётная запись с аудиозаписями удалилась")
|
||||
})
|
||||
|
||||
t.Run("с одними файлами", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
files := NewFileRepository(db, store)
|
||||
work, err := files.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.Error(t, err, "учётная запись с файлами удалилась")
|
||||
})
|
||||
|
||||
t.Run("с одними темами", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
now := clock.Now().Format(timeLayout)
|
||||
_, err := db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
ident.New(), owner, "тема", now, now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.Error(t, err, "учётная запись с темами удалилась")
|
||||
})
|
||||
|
||||
t.Run("пустая удаляется", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
|
||||
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.NoError(t, err, "учётная запись без принадлежностей не удалилась")
|
||||
})
|
||||
}
|
||||
|
||||
// У записи не больше пяти тем, и держит это схема.
|
||||
func TestRecordTopicsAreCapped(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
now := clock.Now().Format(timeLayout)
|
||||
for i := range entity.MaxTopicsPerRecord + 1 {
|
||||
topicID := ident.New()
|
||||
_, err := db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
topicID, owner, "тема-"+topicID, now, now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID,
|
||||
)
|
||||
if i < entity.MaxTopicsPerRecord {
|
||||
require.NoError(t, err, "тема %d не назначилась", i)
|
||||
continue
|
||||
}
|
||||
assert.Error(t, err, "шестая тема назначилась")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим.
|
||||
//
|
||||
// Одна пригодная запись, несколько захватов разом: запись достаётся ровно
|
||||
// одному, остальные получают признак «работы сейчас нет».
|
||||
func TestAcquireGivesRecordToExactlyOne(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
const racers = 8
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
acquired []*contract.AcquiredRecord
|
||||
empty int
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range racers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
got, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
switch {
|
||||
case err == nil:
|
||||
acquired = append(acquired, got)
|
||||
case assert.ErrorAs(t, err, &missing):
|
||||
empty++
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
require.Len(t, acquired, 1, "запись досталась не одному захвату")
|
||||
assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»")
|
||||
assert.Equal(t, record.Id, acquired[0].ID)
|
||||
assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака")
|
||||
}
|
||||
|
||||
// Результат пишет только держатель захвата, и держатель узнаётся **значением**
|
||||
// признака, а не занятостью записи.
|
||||
func TestSaveIsConditionalOnHolderValue(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
first, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Захват уходит другому: срок протухает, и запись достаётся следующему.
|
||||
_, err = db.Writer().ExecContext(context.Background(),
|
||||
"UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
|
||||
clock.Now().Add(-time.Hour).Format(timeLayout), record.Id,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним")
|
||||
|
||||
// Прежний держатель пишет свой результат — и не пишет.
|
||||
stale, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
stale.MoveToState(entity.StateNormalized)
|
||||
|
||||
err = records.Save(stale, first.Holder)
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат")
|
||||
|
||||
after, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние")
|
||||
}
|
||||
|
||||
// Правка владельца переживает сохранение шага: конвейер пишет только те поля,
|
||||
// которыми распоряжается сам.
|
||||
func TestPipelineSaveKeepsOwnerFields(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
acquired, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
held, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец за это время правит поле, которого шаг не касается.
|
||||
_, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
held.MoveToState(entity.StateNormalized)
|
||||
require.NoError(t, records.Save(held, acquired.Holder))
|
||||
|
||||
after, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан")
|
||||
require.NotNil(t, after.Title)
|
||||
assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага")
|
||||
}
|
||||
|
||||
// Составная операция, которая читает и следом пишет, идёт по пишущему
|
||||
// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело.
|
||||
func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
texts := NewTextRepository(db)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор")
|
||||
assert.NoError(t, err, "поток %d отказал по занятости базы", i)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
var count int
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
|
||||
assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста")
|
||||
}
|
||||
|
||||
// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ
|
||||
// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена
|
||||
// стёрла бы расшифровку живого человека без следа.
|
||||
func TestEmptyReplacementKeepsStoredResult(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
texts := NewTextRepository(db)
|
||||
stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
again, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, stored.Id, again.Id, "строка та же")
|
||||
|
||||
read, err := texts.GetByID(stored.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку")
|
||||
|
||||
structures := NewStructureRepository(db)
|
||||
first, err := structures.Put(record.Id, entity.StructureVersion,
|
||||
[]entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = structures.Put(record.Id, entity.StructureVersion, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
structure, err := structures.GetByID(first.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики")
|
||||
|
||||
recognitions := NewRecognitionRepository(db, store)
|
||||
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
|
||||
require.NoError(t, recognitions.Create(attempt))
|
||||
require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера")))
|
||||
require.NoError(t, recognitions.Finish(attempt.Id, nil))
|
||||
|
||||
raw, err := recognitions.ReadRaw(attempt.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера")
|
||||
}
|
||||
|
||||
// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг
|
||||
// опроса читает строку попытки без него.
|
||||
func TestProviderPayloadLivesInRecordDirectory(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
recognitions := NewRecognitionRepository(db, store)
|
||||
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
|
||||
require.NoError(t, recognitions.Create(attempt))
|
||||
require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера")))
|
||||
|
||||
// Строка попытки читается без ответа: он не колонка.
|
||||
read, err := recognitions.GetByID(attempt.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, read.FinishedAt)
|
||||
|
||||
file, err := store.Open(record.Id, attempt.Id+payloadSuffix)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
}
|
||||
|
||||
// Два одновременных первых обращения одним значением дают ровно одну учётную
|
||||
// запись: уникальность держит схема, а не порядок обращений.
|
||||
func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
login := ident.New()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
ids = map[string]bool{}
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
account, _, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
if assert.NoError(t, err) {
|
||||
mu.Lock()
|
||||
ids[account.ID] = true
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи")
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Reader().
|
||||
QueryRowContext(context.Background(),
|
||||
"SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows))
|
||||
assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки")
|
||||
}
|
||||
|
||||
// Занятая почта не мешает завести запись: она необязательна и ключом не служит.
|
||||
func TestBusyEmailDoesNotBlockAccount(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
|
||||
first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
assert.NotEqual(t, first.ID, second.ID)
|
||||
|
||||
var email string
|
||||
require.NoError(t, db.Reader().
|
||||
QueryRowContext(context.Background(),
|
||||
"SELECT email FROM users WHERE id = ?", second.ID).Scan(&email))
|
||||
assert.Empty(t, email, "вторая запись завелась с чужой почтой")
|
||||
}
|
||||
|
||||
// Найденную запись повторное обращение не переписывает: иначе правка имени у
|
||||
// провайдера меняла бы карточку человека молча, посреди его работы.
|
||||
func TestSecondRequestDoesNotRewriteAccount(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
|
||||
first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, created)
|
||||
assert.Equal(t, first.ID, again.ID)
|
||||
assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением")
|
||||
}
|
||||
|
||||
// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом,
|
||||
// и занятый писатель его не держит.
|
||||
//
|
||||
// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в
|
||||
// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая
|
||||
// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу
|
||||
// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, —
|
||||
// и встала бы в очередь к единственному пишущему соединению. Замером триажа
|
||||
// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**:
|
||||
// очередь к соединению ожиданием занятой базы не ограничена.
|
||||
func TestKnownAccountDoesNotTakeWriter(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
login := ident.New()
|
||||
|
||||
first, created, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
// Писателя занимает открытая транзакция: соединение у пишущего пула одно,
|
||||
// поэтому пока она держится, второй транзакции не начаться.
|
||||
tx, err := db.Writer().BeginTx(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
type answer struct {
|
||||
account *contract.UserAccount
|
||||
created bool
|
||||
err error
|
||||
took time.Duration
|
||||
}
|
||||
done := make(chan answer, 1)
|
||||
go func() {
|
||||
started := time.Now()
|
||||
account, created, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
done <- answer{account: account, created: created, err: err, took: time.Since(started)}
|
||||
}()
|
||||
|
||||
// Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее
|
||||
// узнавание не дождётся вовсе — транзакцию отпускают уже после проверки.
|
||||
const limit = time.Second
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
require.NoError(t, got.err)
|
||||
assert.False(t, got.created, "узнавание известного завело вторую учётную запись")
|
||||
assert.Equal(t, first.ID, got.account.ID)
|
||||
t.Logf("узнавание известного заняло %s при занятом писателе", got.took)
|
||||
case <-time.After(limit):
|
||||
t.Errorf("узнавание известного ждёт писателя дольше %s", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно
|
||||
// там, где никого не назвал.
|
||||
func TestUnacceptableLoginCreatesNothing(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
|
||||
for name, login := range map[string]string{
|
||||
"пустой": "",
|
||||
"одни пробелы": " ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable)
|
||||
})
|
||||
}
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows))
|
||||
assert.Equal(t, 0, rows, "негодный логин завёл учётную запись")
|
||||
}
|
||||
|
||||
// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу.
|
||||
//
|
||||
// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля
|
||||
// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое
|
||||
// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата
|
||||
// держит запись занятой до протухания срока, оставленное время входа в рубеж
|
||||
// останавливает её снова первым же захватом, а оставленные отказы и пауза
|
||||
// откладывают первый прогон на накопленный срок.
|
||||
func TestResumeReleasesEveryGuard(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
records := NewAudioRecordRepository(db)
|
||||
events := NewRecordEventRepository(db)
|
||||
|
||||
// Так выглядит запись, остановленная после долгих отказов: захват на ней
|
||||
// стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в
|
||||
// рубеже она простояла дольше предела.
|
||||
_, err := db.Writer().ExecContext(context.Background(), `
|
||||
UPDATE audio_records
|
||||
SET halted_at = ?, halt_reason = ?, error_text = ?,
|
||||
acquisition_id = ?, acquire_expires_at = ?,
|
||||
delay_time = ?, attempts = 7, state_entered_at = ?
|
||||
WHERE id = ?`,
|
||||
clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы",
|
||||
ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout),
|
||||
clock.Now().Add(time.Hour).Format(timeLayout),
|
||||
clock.Now().Add(-48*time.Hour).Format(timeLayout),
|
||||
record.Id,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Остановленная запись захвату не выдаётся.
|
||||
_, err = records.FindAndAcquire(entity.WorkingStages())
|
||||
var missing *contract.JobNotFoundError
|
||||
require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату")
|
||||
|
||||
halted, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
halted.Resume()
|
||||
require.NoError(t, records.Save(halted, ""))
|
||||
require.NoError(t, events.Append(&entity.RecordEvent{
|
||||
RecordID: record.Id,
|
||||
Origin: entity.EventOriginHuman,
|
||||
Step: "resume",
|
||||
Outcome: entity.EventOutcomeResumed,
|
||||
}))
|
||||
|
||||
after, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, after.IsHalted(), "признак остановки остался")
|
||||
assert.Nil(t, after.HaltReason, "причина остановки осталась")
|
||||
assert.Nil(t, after.ErrorText, "машинный текст отказа остался")
|
||||
assert.Nil(t, after.AcquisitionID, "признак захвата остался")
|
||||
assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался")
|
||||
assert.Nil(t, after.DelayTime, "пауза перед повтором осталась")
|
||||
assert.Equal(t, 0, after.Attempts, "число отказов осталось")
|
||||
assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute,
|
||||
"время входа в рубеж не сброшено: запись остановится снова первым же захватом")
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу")
|
||||
|
||||
// Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока.
|
||||
acquired, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "возвращённая в работу запись захвату не досталась")
|
||||
assert.Equal(t, record.Id, acquired.ID)
|
||||
|
||||
// И возврат виден в журнале событий записи — происхождением «человек».
|
||||
var origin, outcome string
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
|
||||
"SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id,
|
||||
).Scan(&origin, &outcome))
|
||||
assert.Equal(t, entity.EventOriginHuman, origin)
|
||||
assert.Equal(t, entity.EventOutcomeResumed, outcome)
|
||||
}
|
||||
|
||||
// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до
|
||||
// строки о готовности схема приведена целиком.
|
||||
func TestCleanStartLeavesNoFailureInJournal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
journal := &strings.Builder{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
db, err := Open(dir, testSettings())
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
require.NoError(t, Migrate(context.Background(), db, dir, logger))
|
||||
|
||||
assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале")
|
||||
assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале")
|
||||
assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался")
|
||||
|
||||
// И хранилище готово принимать записи сразу: ручного шага между подъёмом и
|
||||
// первым приёмом нет.
|
||||
owner := newOwner(t, db)
|
||||
newWorkingRecord(t, db, owner)
|
||||
}
|
||||
Reference in New Issue
Block a user