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) } } }