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) }