внутренняя модель перестроена вокруг аудиозаписи

- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
This commit is contained in:
av
2026-08-14 20:20:33 +03:00
parent d079f03350
commit 1576d06735
84 changed files with 8973 additions and 2865 deletions
+160
View File
@@ -0,0 +1,160 @@
package http
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Ответ об одной записи — то, ради чего эндпойнт и существует; ниже судятся его
// ветки: готовый текст, остановленная запись и отказ хранилища на чтении текста.
// statusOf спрашивает состояние записи от имени её владельца.
func statusOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/api/status/"+recordID, http.NoBody))
return w
}
// Готовая расшифровка доезжает до отправителя полем `transcription_text`, и
// уходит в него **сырая** расшифровка: видов текста больше одного, и отдача
// «последнего записанного» сделала бы ответ функцией порядка записи.
func TestGetTranscribeJobStatus_ReturnsTranscript(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
record.LiteraryTextID = &literary.Id
record.MoveToState(entity.StateDone)
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := statusOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, entity.StateDone, response.State)
require.NotNil(t, response.TranscriptionText, "готовый текст доехал до отправителя")
assert.Equal(t, "сырая расшифровка", *response.TranscriptionText)
assert.NotContains(t, w.Body.String(), "вычитанный текст",
"вычитанный текст этим полем не подменяется: значение поля не должно меняться от того, успел ли необязательный шаг")
}
// Остановленная запись отдаёт рубеж, на котором встала, и признак остановки
// отдельным полем: отказ перестал быть состоянием, и без признака такая запись
// выглядела бы обычной, стоящей на своём рубеже.
func TestGetTranscribeJobStatus_HaltedIsVisible(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
record.MoveToState(entity.StateNormalized)
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
require.NoError(t, env.handler.recordRepo.Save(record, ""))
w := statusOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, entity.StateNormalized, response.State, "рубеж тот, на котором запись встала")
assert.True(t, response.Halted, "признак остановки виден отправителю")
assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
"машинный текст отказа принадлежит журналу владельца, а не ответу отправителю")
}
// Пока запись не дошла до текста, поля нет вовсе: пустая строка на его месте
// читается как «расшифровка пуста».
func TestGetTranscribeJobStatus_RunningRecordHasNoHaltedFlag(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
w := statusOf(t, env, record.Id)
require.Equal(t, http.StatusOK, w.Code)
var response GetTranscribeJobResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
assert.Equal(t, entity.StateUploaded, response.State)
assert.False(t, response.Halted, "запись в работе остановленной не значится")
assert.Nil(t, response.TranscriptionText)
}
// failingTextRepo отказывает на чтении текста — так выглядит недоступное
// хранилище. Битую ссылку схема завести не даёт (связь проверяется при
// сохранении), и это её защита, а не пробел: остаётся отказ самого чтения.
type failingTextRepo struct{}
func (r *failingTextRepo) Put(string, string, string) (*entity.Text, error) {
return nil, errors.New("не зовётся этой проверкой")
}
func (r *failingTextRepo) GetByID(string) (*entity.Text, error) {
return nil, errors.New("хранилище недоступно")
}
// Отказ чтения текста — это отказ хранилища, а не «записи нет». Отправителю он
// приходит своим кодом, и владелец сервиса узнаёт об аварии из журнала — иначе
// она читалась бы отправителю как «вашей записи не существует», а владельцем не
// замечалась бы вовсе.
func TestGetTranscribeJobStatus_TextReadFailureIsNotANotFound(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
// цепочка та же, что и в проде.
journal := &journalBuffer{}
handler := NewTranscribeHandler(
env.handler.recordRepo,
&failingTextRepo{},
env.handler.trsService,
slog.New(slog.NewTextHandler(journal, nil)),
)
r, err := apis.NewRouter(env.app)
require.NoError(t, err)
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code,
"отказ хранилища не выдаётся за отсутствие записи")
assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят")
assert.Contains(t, journal.String(), "Failed to read transcript",
"владелец сервиса узнаёт об аварии из журнала")
}