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, newAccount(t, app).Id) 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, newAccount(t, app).Id) 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, newAccount(t, app).Id) 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, newAccount(t, app).Id) 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, newAccount(t, app).Id) 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, newAccount(t, app).Id) 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, "признак нового захвата отличается от прежнего") }