Files
healthlog/internal/store/migration_test.go
T
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

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, &sections)
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)
}
}
}