хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
+152
-40
@@ -113,6 +113,31 @@ func TestТранспортыНеЗнаютДругОДруге(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Транспорт не знает адаптеров. Изъятие, разрешавшее ему знать адаптер
|
||||
// хранилища, снято вместе с предметом: HTTP-поверхность была роутером
|
||||
// встроенного хранилища, а стала своей, и правило на это направление заводится
|
||||
// впервые.
|
||||
//
|
||||
// Что оно ловит: возврат `EnsureUser`, `NewFileRepository` и прочих имён
|
||||
// адаптера в обработчики. Знание о внешнем мире приходит транспорту интерфейсом
|
||||
// `internal/contract`, а реализацию подставляет точка входа.
|
||||
func TestТранспортыНеЗнаютАдаптеров(t *testing.T) {
|
||||
for pkg, imports := range internalImports(t) {
|
||||
if !transports[pkg] {
|
||||
continue
|
||||
}
|
||||
for _, imp := range imports {
|
||||
if strings.HasPrefix(imp, adapterPrefix) {
|
||||
t.Errorf(
|
||||
"транспорт %s импортирует адаптер %s: реализацию подставляет "+
|
||||
"cmd/transcriber, а транспорт знает только internal/contract",
|
||||
pkg, imp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
|
||||
for pkg, imports := range internalImports(t) {
|
||||
if !strings.HasPrefix(pkg, adapterPrefix) {
|
||||
@@ -173,16 +198,19 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
|
||||
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
|
||||
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
|
||||
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
|
||||
// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
|
||||
// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
|
||||
// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
|
||||
// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
|
||||
// полем, и первое же сохранение пишет этот ноль поверх значения.
|
||||
//
|
||||
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
|
||||
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
|
||||
// ниже держат оставшуюся пару плюс шаг схемы.
|
||||
// Отображение работает **по имени колонки** — именованные параметры запроса и
|
||||
// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
|
||||
// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
|
||||
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
|
||||
// отображения нет вовсе.
|
||||
const (
|
||||
repoPkg = "internal/adapter/repo/pocketbase"
|
||||
repoPkg = "internal/adapter/repo/sqlite"
|
||||
mappingFile = repoPkg + "/record_mapping.go"
|
||||
migrationsPath = repoPkg + "/migrations"
|
||||
stageFile = "internal/entity/stage.go"
|
||||
@@ -190,46 +218,64 @@ const (
|
||||
serviceFile = "internal/service/transcribe.go"
|
||||
)
|
||||
|
||||
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
|
||||
// касаются.
|
||||
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
|
||||
|
||||
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
|
||||
written := writtenColumns(t)
|
||||
read := readColumns(t)
|
||||
|
||||
for col := range written {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !read[col] {
|
||||
t.Errorf(
|
||||
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
|
||||
"читает: запись приедет из хранилища без этого поля",
|
||||
"колонку %q пишет отображение записи, но readRecordColumns её не "+
|
||||
"читает: запись приедет из базы без этого поля",
|
||||
col,
|
||||
)
|
||||
}
|
||||
}
|
||||
for col := range read {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !written[col] {
|
||||
t.Errorf(
|
||||
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
|
||||
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
|
||||
"колонку %q читает readRecordColumns, но её не пишет ни "+
|
||||
"writeOwnedByPipeline, ни writeRecord: поле не сохранится",
|
||||
col,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности:
|
||||
// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord`
|
||||
// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча —
|
||||
// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в
|
||||
// сырой строке, сущность получает нулевое значение, а ближайшее сохранение
|
||||
// пишет этот ноль поверх сохранённого.
|
||||
func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) {
|
||||
targets := readTargets(t)
|
||||
used := rowFieldsTakenByEntity(t)
|
||||
|
||||
for field, column := range targets {
|
||||
if !used[field] {
|
||||
t.Errorf(
|
||||
"колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+
|
||||
"значение не доедет до сущности, а ближайшее сохранение запишет "+
|
||||
"нулевое поверх сохранённого",
|
||||
column, field,
|
||||
)
|
||||
}
|
||||
}
|
||||
for field := range used {
|
||||
if _, ok := targets[field]; !ok {
|
||||
t.Errorf(
|
||||
"rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+
|
||||
"колонки в него не кладёт: сущность получит нулевое значение всегда",
|
||||
field,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
|
||||
declared := schemaFieldNames(t)
|
||||
for col := range writtenColumns(t) {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !declared[col] {
|
||||
t.Errorf(
|
||||
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
|
||||
@@ -312,13 +358,16 @@ func TestШагиОбъявленыРубежамиДескриптора(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
|
||||
// columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
|
||||
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)
|
||||
|
||||
// writtenColumns — колонки, которые пишет отображение записи в базу.
|
||||
func writtenColumns(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
||||
funcBody(t, mappingFile, "func applyToRecord(")
|
||||
body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
|
||||
funcBody(t, mappingFile, "func writeRecord(")
|
||||
out := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
|
||||
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
@@ -327,16 +376,51 @@ func writtenColumns(t *testing.T) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// readColumns — колонки, которые читает обратное отображение.
|
||||
// readColumns — колонки, которые читает обратное отображение. Перечень выборки
|
||||
// собирается из той же карты, поэтому сверяется именно она.
|
||||
func readColumns(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
|
||||
body := funcBody(t, mappingFile, "func readRecordColumns(")
|
||||
out := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
|
||||
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
|
||||
t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readTarget — колонка чтения и поле сырой строки, куда она ложится.
|
||||
var readTarget = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":\s*&row\.(\w+),`)
|
||||
|
||||
// rowFieldUse — обращение к полю сырой строки при сборке сущности.
|
||||
var rowFieldUse = regexp.MustCompile(`\brow\.(\w+)\b`)
|
||||
|
||||
// readTargets — поле сырой строки в имя колонки, которая в него читается.
|
||||
func readTargets(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func readRecordColumns(")
|
||||
out := map[string]string{}
|
||||
for _, m := range readTarget.FindAllStringSubmatch(body, -1) {
|
||||
out[m[2]] = m[1]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("readRecordColumns не кладёт ни одной колонки в поле строки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rowFieldsTakenByEntity — поля сырой строки, которые берёт сборка сущности.
|
||||
func rowFieldsTakenByEntity(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func rowToAudioRecord(")
|
||||
out := map[string]bool{}
|
||||
for _, m := range rowFieldUse.FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("rowToAudioRecord не берёт ни одного поля строки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -447,9 +531,11 @@ func funcBody(t *testing.T, file, header string) string {
|
||||
return body[start : start+end]
|
||||
}
|
||||
|
||||
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в
|
||||
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг,
|
||||
// который её добавил, а переписывать применённый шаг нельзя.
|
||||
// schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы.
|
||||
//
|
||||
// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
|
||||
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
|
||||
// поэтому объединённый — по всем шагам сразу.
|
||||
func schemaFieldNames(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
dir := filepath.Join(repoRoot, migrationsPath)
|
||||
@@ -457,7 +543,8 @@ func schemaFieldNames(t *testing.T) map[string]bool {
|
||||
if err != nil {
|
||||
t.Fatalf("читаю каталог шагов схемы: %v", err)
|
||||
}
|
||||
re := regexp.MustCompile(`Name:\s*"([^"]+)"`)
|
||||
|
||||
column := regexp.MustCompile(`(?m)^\s*([a-z_0-9]+)\s+(TEXT|INTEGER)`)
|
||||
out := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||
@@ -467,16 +554,41 @@ func schemaFieldNames(t *testing.T) map[string]bool {
|
||||
if err != nil {
|
||||
t.Fatalf("читаю %s: %v", e.Name(), err)
|
||||
}
|
||||
for _, m := range re.FindAllStringSubmatch(string(body), -1) {
|
||||
out[m[1]] = true
|
||||
for _, block := range tableBlocks(string(body), recordsTable) {
|
||||
for _, m := range column.FindAllStringSubmatch(block, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет")
|
||||
t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordsTable — имя таблицы аудиозаписей в шагах схемы.
|
||||
const recordsTable = "audio_records"
|
||||
|
||||
// tableBlocks вырезает объявления названной таблицы: от `CREATE TABLE имя (` до
|
||||
// закрывающей скобки в начале строки.
|
||||
func tableBlocks(body, table string) []string {
|
||||
var out []string
|
||||
marker := "CREATE TABLE " + table + " ("
|
||||
for {
|
||||
start := strings.Index(body, marker)
|
||||
if start < 0 {
|
||||
return out
|
||||
}
|
||||
body = body[start+len(marker):]
|
||||
end := strings.Index(body, "\n\t\t)")
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
out = append(out, body[:end])
|
||||
body = body[end:]
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot, rel))
|
||||
|
||||
Reference in New Issue
Block a user