- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
165 lines
6.6 KiB
Go
165 lines
6.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"io/fs"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
"github.com/pressly/goose/v3"
|
|
)
|
|
|
|
// Миграция 00005 переводит существующие `parsed` в `pending`. Статус, который
|
|
// поставил код, не различавший частичного разбора, ничего не доказывает: под
|
|
// ним лежат и полностью разобранные доставки, и доставки из одних тренировок с
|
|
// нулём точек (48 из 99 на живом архиве). Ретеншен, ради которого признак и
|
|
// заводится, поверил бы им и срезал тела — а для stateOfMind это необратимо.
|
|
func TestMigrationПрежниеParsedСтановятсяPending(t *testing.T) {
|
|
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
|
|
if err != nil {
|
|
t.Fatalf("открытие базы: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
sub, err := fs.Sub(migrationsFS, "migrations")
|
|
if err != nil {
|
|
t.Fatalf("миграции: %v", err)
|
|
}
|
|
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
|
if err != nil {
|
|
t.Fatalf("провайдер: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Состояние ДО этой миграции: схема четвёртой версии.
|
|
if _, err := p.UpTo(ctx, 4); err != nil {
|
|
t.Fatalf("миграция до 4: %v", err)
|
|
}
|
|
|
|
const insert = `
|
|
INSERT INTO delivery (id, received_at, automation_name, automation_id,
|
|
aggregation, period, session_id, bytes, sha256, raw_path,
|
|
parse_status, points)
|
|
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, ?)`
|
|
for _, c := range []struct {
|
|
id string
|
|
status string
|
|
points int
|
|
}{
|
|
{"d-parsed-points", "parsed", 12},
|
|
{"d-parsed-empty", "parsed", 0},
|
|
{"d-failed", "failed", 0},
|
|
{"d-pending", "pending", 0},
|
|
} {
|
|
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.points); err != nil {
|
|
t.Fatalf("вставка %s: %v", c.id, err)
|
|
}
|
|
}
|
|
|
|
if _, err := p.Up(ctx); err != nil {
|
|
t.Fatalf("миграция до последней: %v", err)
|
|
}
|
|
|
|
want := map[string]string{
|
|
// Переводятся ОБА варианта parsed, а не только пустой: наблюдение
|
|
// «секции не смешиваются» собрано за двое суток потока, и ставить на
|
|
// него необратимое удаление тел значит повторять ошибку, ради которой
|
|
// задача и заведена.
|
|
"d-parsed-points": "pending",
|
|
"d-parsed-empty": "pending",
|
|
"d-failed": "failed",
|
|
"d-pending": "pending",
|
|
}
|
|
for id, wantStatus := range want {
|
|
var status, sections string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT parse_status, uncovered_sections FROM delivery WHERE id = ?`, id).
|
|
Scan(&status, §ions)
|
|
if err != nil {
|
|
t.Fatalf("чтение %s: %v", id, err)
|
|
}
|
|
if status != wantStatus {
|
|
t.Errorf("%s: статус %q, ожидался %q", id, status, wantStatus)
|
|
}
|
|
if sections != "[]" {
|
|
t.Errorf("%s: список %q, ожидался `[]`", id, sections)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Миграция 00007 исполняет правило «покрыли секцию — пересверните»: список
|
|
// непокрытых ключей это снимок покрытия на момент свёртки, и доставки,
|
|
// свёрнутые до того, как workouts и stateOfMind стали покрытыми, остались бы
|
|
// `partial` со старым списком навсегда — а ретеншен вечно щадил бы их тела.
|
|
//
|
|
// Перевод ТОЧЕЧНЫЙ: доставка, у которой непокрыта только `ecg`, пересворачивать
|
|
// нечего, и трогать её значило бы гонять весь архив на каждую новую секцию.
|
|
func TestMigrationПокрытыеСекцииВозвращаютсяВОчередь(t *testing.T) {
|
|
db, err := sqlx.Connect("sqlite", dsn(filepath.Join(t.TempDir(), "healthlog.db")))
|
|
if err != nil {
|
|
t.Fatalf("открытие базы: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
sub, err := fs.Sub(migrationsFS, "migrations")
|
|
if err != nil {
|
|
t.Fatalf("миграции: %v", err)
|
|
}
|
|
p, err := goose.NewProvider(goose.DialectSQLite3, db.DB, sub)
|
|
if err != nil {
|
|
t.Fatalf("провайдер: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if _, err := p.UpTo(ctx, 6); err != nil {
|
|
t.Fatalf("миграция до 6: %v", err)
|
|
}
|
|
|
|
const insert = `
|
|
INSERT INTO delivery (id, received_at, automation_name, automation_id,
|
|
aggregation, period, session_id, bytes, sha256, raw_path,
|
|
parse_status, points, uncovered_sections)
|
|
VALUES (?, '2025-06-05T10:00:00Z', '', '', '', '', '', 0, '-', '-', ?, 0, ?)`
|
|
cases := []struct {
|
|
id string
|
|
status string
|
|
uncovered string
|
|
want string
|
|
}{
|
|
{"d-workouts", ParsePartial, `["workouts"]`, ParsePending},
|
|
{"d-mind", ParsePartial, `["stateOfMind"]`, ParsePending},
|
|
{"d-both", ParsePartial, `["stateOfMind","workouts"]`, ParsePending},
|
|
{"d-mixed", ParsePartial, `["ecg","workouts"]`, ParsePending},
|
|
// Ничего из ставшего покрытым: трогать нечего.
|
|
{"d-ecg", ParsePartial, `["ecg"]`, ParsePartial},
|
|
// Подстрока имени секции — не имя секции: отбор идёт по элементу
|
|
// массива, иначе чужое тело управляло бы тем, что мы пересворачиваем.
|
|
{"d-lookalike", ParsePartial, `["myworkoutsx"]`, ParsePartial},
|
|
// Статус `failed` возвращает только пересборка, а `parsed` этой
|
|
// миграцией не трогается: у него пустой список непокрытых.
|
|
{"d-failed", ParseFailed, `["workouts"]`, ParseFailed},
|
|
{"d-parsed", ParseDone, `[]`, ParseDone},
|
|
}
|
|
for _, c := range cases {
|
|
if _, err := db.ExecContext(ctx, insert, c.id, c.status, c.uncovered); err != nil {
|
|
t.Fatalf("вставка %s: %v", c.id, err)
|
|
}
|
|
}
|
|
|
|
if _, err := p.UpTo(ctx, 7); err != nil {
|
|
t.Fatalf("миграция до 7: %v", err)
|
|
}
|
|
|
|
for _, c := range cases {
|
|
var got string
|
|
if err := db.GetContext(ctx, &got, `SELECT parse_status FROM delivery WHERE id = ?`, c.id); err != nil {
|
|
t.Fatalf("чтение %s: %v", c.id, err)
|
|
}
|
|
if got != c.want {
|
|
t.Errorf("%s: статус %q, ожидался %q (непокрытые %s)", c.id, got, c.want, c.uncovered)
|
|
}
|
|
}
|
|
}
|