Files
transcriber/internal/adapter/repo/pocketbase/panel_test.go
T
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00

241 lines
12 KiB
Go

package pocketbase
import (
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"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/entity"
)
// Панель — единственный сегодня путь вернуть остановленную запись в работу, и
// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает,
// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь
// того пути, которым владелец и ходит.
// newPanelStorage поднимает хранилище с повешенными правилами панели — так же,
// как это делает сборка сервиса. Без них проверки судили бы хранилище без
// правил, то есть не то, что работает в проде.
func newPanelStorage(t *testing.T) core.App {
t.Helper()
app := newTestStorage(t)
BindPanelRules(app)
return app
}
// updateByRequest правит запись так, как это делает панель: запросом, а не
// сохранением модели.
func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record {
t.Helper()
record, err := app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
// Запись, прочитанная из хранилища, помнит прежние значения сама — по ним
// хук и отличает смену рубежа от правки соседнего поля.
for key, value := range body {
record.Set(key, value)
}
// Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен
// указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе,
// и проверка зеленела бы, не коснувшись правила.
collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
require.NoError(t, err)
event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}}
event.App = app
event.Collection = collection
event.Record = record
require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error {
return e.App.Save(e.Record)
}))
after, err := app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
return after
}
// haltedRecord заводит остановленную запись со всеми накопленными сторожами —
// такой её видит владелец, открывая панель.
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
t.Helper()
record := newRecordOf(t, app, "")
record.MoveToState(entity.StateNormalized)
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
record.DelayTime = ptrOf(clock.Now().Add(time.Hour))
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
// Время входа в рубеж отодвигаем: запись простояла остановленной дольше
// предела простоя, и это ровно тот случай, ради которого сторож сбрасывается.
record.StateEnteredAt = clock.Now().Add(-24 * time.Hour)
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
return record
}
func ptrOf[T any](v T) *T { return &v } //nolint:newexpr // значение вычисляется, new(x) его не примет
// Снятие признака остановки возвращает запись в работу с сохранённого рубежа и
// сбрасывает **всех** сторожей. Без сброса времени входа в рубеж запись,
// простоявшая остановленной дольше предела, остановилась бы снова первым же
// захватом — и владелец не узнал бы об этом.
func TestPanelResumeClearsEveryGuard(t *testing.T) {
app := newPanelStorage(t)
record := haltedRecord(t, app)
after := updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
assert.Equal(t, entity.StateNormalized, after.GetString("state"), "рубеж сохранён")
assert.True(t, after.GetDateTime("halted_at").IsZero(), "признак остановки снят")
assert.Empty(t, after.GetString("halt_reason"), "причина снята вместе с ним")
assert.Empty(t, after.GetString("error_text"), "и текст отказа")
assert.Empty(t, after.GetString("acquisition_id"), "признак прежнего захвата очищен")
assert.True(t, after.GetDateTime("acquire_expires_at").IsZero(), "срок протухания тоже")
assert.True(t, after.GetDateTime("delay_time").IsZero(), "пауза снята")
assert.Equal(t, 0, after.GetInt("attempts"), "отказы сброшены")
entered := after.GetDateTime("state_entered_at").Time()
assert.WithinDuration(t, clock.Now(), entered, time.Minute,
"время входа в рубеж поставлено заново: иначе сторож простоя остановит запись снова")
// И ближайший захват её выдаёт — то есть перезапуск действительно работает.
acquired, err := NewAudioRecordRepository(app).FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "запись вернулась в выборку")
assert.Equal(t, record.Id, acquired.ID)
}
// Перезапуск виден в журнале событий с указанием, что его сделал человек: иначе
// запись, вернувшаяся в работу, выглядела бы как запись, которая туда и не
// уходила.
func TestPanelResumeIsLogged(t *testing.T) {
app := newPanelStorage(t)
record := haltedRecord(t, app)
updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
events, err := app.FindAllRecords(migrations.RecordEventsCollection)
require.NoError(t, err)
var human int
for _, event := range events {
if event.GetString("record") == record.Id && event.GetString("origin") == entity.EventOriginHuman {
human++
assert.Equal(t, entity.EventOutcomeResumed, event.GetString("outcome"))
}
}
assert.Equal(t, 1, human, "ровно одна строка о перезапуске человеком")
}
// Правка рубежа руками чистит служебные поля прошлого захвата так же, как
// снятие остановки: иначе владелец, «вернувший запись в работу» сменой рубежа,
// получит запись, которая не выдаётся захвату до конца прежнего срока.
func TestPanelStateEditClearsGuards(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, "")
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
after := updateByRequest(t, app, record.Id, map[string]any{"state": entity.StateNormalized})
assert.Equal(t, entity.StateNormalized, after.GetString("state"))
assert.Empty(t, after.GetString("acquisition_id"))
assert.Equal(t, 0, after.GetInt("attempts"))
}
// Правка соседнего поля служебных полей не трогает: хук судит смену рубежа и
// снятие остановки, а не всякое сохранение. Иначе владелец, поправивший
// заголовок, снял бы захват у работающего шага.
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, "")
record.Attempts = 3
record.AcquisitionID = ptrOf("живой-захват")
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
after := updateByRequest(t, app, record.Id, map[string]any{"title": "Разговор с бабушкой"})
assert.Equal(t, "Разговор с бабушкой", after.GetString("title"))
assert.Equal(t, "живой-захват", after.GetString("acquisition_id"), "захват работающего шага не снят")
assert.Equal(t, 3, after.GetInt("attempts"), "отказы не сброшены")
}
// Захват отдаёт идентификатор и признак **этого** захвата, а срок протухания
// приезжает с рубежом: воркер не привязан к шагу и вывести срок из себя не
// может.
func TestAcquireCarriesStageDeadline(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, "")
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
require.Equal(t, record.Id, acquired.ID)
require.NotEmpty(t, acquired.Holder)
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, acquired.Holder, stored.GetString("acquisition_id"))
stage, ok := entity.StageByName(entity.StateUploaded)
require.True(t, ok)
expected := clock.Now().Add(stage.AcquireTimeout)
assert.WithinDuration(t, expected, stored.GetDateTime("acquire_expires_at").Time(), time.Minute,
"срок протухания приехал с рубежа записи")
}
// Одна запись достаётся ровно одному захвату: на этом стоит инвариант «Принятая
// запись не теряется молча».
func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
newRecordOf(t, app, "")
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "первому запись досталась")
require.NotEmpty(t, first.Holder)
for range 2 {
_, err = repo.FindAndAcquire(entity.WorkingStages())
require.Error(t, err, "остальным — признак «работы нет»")
}
}
// Протухший захват возвращает запись в работу, и признак нового захвата
// отличается от прежнего: условие записи результата сверяет именно значение.
func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, "")
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
stored.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
require.NoError(t, app.Save(stored))
second, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "протухший захват не мешает выдать запись следующему")
assert.Equal(t, record.Id, second.ID)
assert.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается от прежнего")
}