- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
241 lines
12 KiB
Go
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, 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, "признак нового захвата отличается от прежнего")
|
|
}
|