package store import ( "context" "database/sql" "testing" "github.com/jmoiron/sqlx" "github.com/pressly/goose/v3" ) // TestUlidMigration прогоняет миграцию 0006 на фикстурной БД со старой схемой // (числовые id, download.infohash + idempotency_key) и проверяет: FK-связи // сохранены, порядок по id соответствует created_at, хеши разнесены в // download_infohash, старые столбцы удалены. func TestUlidMigration(t *testing.T) { dbPath := t.TempDir() + "/legacy.db" // Legacy-БД: схема до 0006 + строки с числовыми id. legacy, err := sqlx.Connect("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") if err != nil { t.Fatalf("open legacy: %v", err) } goose.SetBaseFS(migrationsFS) goose.SetLogger(goose.NopLogger()) if err := goose.SetDialect("sqlite3"); err != nil { t.Fatal(err) } if err := goose.UpTo(legacy.DB, "migrations", 5); err != nil { t.Fatalf("migrate to v5: %v", err) } const ( v1hash = "aabbccddeeff00112233445566778899aabbccdd" v2hash = "6666666666666666666666666666666666666666666666666666666666666666" ) seed := []string{ // #1 done с v1-хешем (ключ снят терминалом), #2 активная с v2-хешем, // #3 без хеша; #2 и #3 в одну секунду — порядок должен сохраниться. `INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at) VALUES (1, 'magnet', 'magnet:?xt=urn:btih:` + v1hash + `', 'Old One', 'done', '` + v1hash + `', NULL, '2026-01-01 10:00:00', '2026-01-01 11:00:00')`, `INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at) VALUES (2, 'magnet', 'magnet:?xt=urn:btmh:1220` + v2hash + `', 'Two', 'downloading', '` + v2hash + `', '` + v2hash + `', '2026-02-01 10:00:00', '2026-02-01 10:00:00')`, `INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at) VALUES (3, 'magnet', 'magnet:?xt=urn:btih:cafe', 'Three', 'failed', NULL, NULL, '2026-02-01 10:00:00', '2026-02-01 10:00:00')`, `INSERT INTO recognition (id, download_id, attempt_no, is_current, title, provider, provider_id, plan) VALUES (10, 1, 1, 1, 'Fargo', 'tvdb', '269613', '{"type":"series"}')`, `INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, chosen) VALUES (20, 10, 'tvdb', '269613', 'Fargo', 1)`, `INSERT INTO hint (id, download_id, text) VALUES (30, 1, 'второй сезон')`, `INSERT INTO override (id, download_id, field, value) VALUES (40, 1, 'media_type', 'series')`, `INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status) VALUES (50, 1, 'b-1', '/d/a.mkv', '/m/A.mkv', 'video', 'linked')`, } for _, q := range seed { if _, err := legacy.Exec(q); err != nil { t.Fatalf("seed: %v\n%s", err, q) } } if err := legacy.Close(); err != nil { t.Fatal(err) } // Open прогоняет оставшиеся миграции (0006). st, err := Open(dbPath) if err != nil { t.Fatalf("open with migration: %v", err) } t.Cleanup(func() { _ = st.Close() }) ctx := context.Background() all, err := st.ListDownloads(ctx) // ORDER BY id DESC if err != nil { t.Fatal(err) } if len(all) != 3 { t.Fatalf("downloads = %d, want 3", len(all)) } // Хронология сохранена: DESC по id = [Three, Two, Old One] (при равных // секундах #2/#3 порядок старых id держит monotonic-энтропия). if all[0].DisplayName != "Three" || all[1].DisplayName != "Two" || all[2].DisplayName != "Old One" { t.Fatalf("порядок по id разъехался с хронологией: %s, %s, %s", all[0].DisplayName, all[1].DisplayName, all[2].DisplayName) } one, two, three := all[2], all[1], all[0] // Хеши разнесены с верным kind; created_at/updated_at сохранены. if len(one.Infohashes) != 1 || one.Infohashes[0].Infohash != v1hash || one.Infohashes[0].Kind != HashV1 { t.Fatalf("хеши #1 = %+v", one.Infohashes) } if len(two.Infohashes) != 1 || two.Infohashes[0].Infohash != v2hash || two.Infohashes[0].Kind != HashV2 { t.Fatalf("хеши #2 = %+v", two.Infohashes) } if len(three.Infohashes) != 0 { t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes) } // Метки переформатированы миграцией 0008 в RFC 3339 (UTC, суффикс Z) — // момент сохранён; заодно канарейка, что backfill 0008 отработал на данных. if one.CreatedAt != "2026-01-01T10:00:00Z" || one.UpdatedAt != "2026-01-01T11:00:00Z" { t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt) } // FK-связи: распознавание/кандидаты/подсказки/правки/ссылки указывают на #1. rec, err := st.GetCurrentRecognition(ctx, one.ID) if err != nil || rec == nil || rec.Title.String != "Fargo" { t.Fatalf("recognition #1 = %+v, %v", rec, err) } cands, err := st.ListCandidatesByRecognition(ctx, rec.ID) if err != nil || len(cands) != 1 || !cands[0].Chosen { t.Fatalf("candidates = %+v, %v", cands, err) } hints, err := st.ListHints(ctx, one.ID) if err != nil || len(hints) != 1 || hints[0] != "второй сезон" { t.Fatalf("hints = %v, %v", hints, err) } ovr, err := st.ListOverrides(ctx, one.ID) if err != nil || ovr["media_type"] != "series" { t.Fatalf("overrides = %v, %v", ovr, err) } batch, err := st.LatestBatchID(ctx, one.ID) if err != nil || batch != "b-1" { t.Fatalf("batch = %q, %v", batch, err) } // Активность выводится из state: v2-хеш занят активной #2. active, err := st.FindActiveByInfohash(ctx, v2hash) if err != nil || active == nil || active.ID != two.ID { t.Fatalf("active by v2 = %+v, %v", active, err) } // Старые столбцы удалены. var cols []string if err := st.DB.Select(&cols, `SELECT name FROM pragma_table_info('download')`); err != nil { t.Fatal(err) } for _, c := range cols { if c == "infohash" || c == "idempotency_key" { t.Fatalf("столбец %q должен быть удалён", c) } } } // TestRFC3339TimeMigration проверяет миграцию 0008: при Up метки переформатиру- // ются из datetime('now') (пробел) в RFC 3339 (UTC, суффикс Z), при Down — обра- // тно; NULL source_added_at не затрагивается, момент времени сохраняется. func TestRFC3339TimeMigration(t *testing.T) { dbPath := t.TempDir() + "/time.db" db, err := sqlx.Connect("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") if err != nil { t.Fatalf("open: %v", err) } defer func() { _ = db.Close() }() goose.SetBaseFS(migrationsFS) goose.SetLogger(goose.NopLogger()) if err := goose.SetDialect("sqlite3"); err != nil { t.Fatal(err) } // Схема до 0008: DEFAULT (datetime('now')), формат меток с пробелом. if err := goose.UpTo(db.DB, "migrations", 7); err != nil { t.Fatalf("up to 7: %v", err) } // Строка со старой меткой; source_added_at = NULL (проверяем щажение). if _, err := db.Exec(`INSERT INTO download (id, source_type, source_ref, state, created_at, updated_at) VALUES ('d1', 'magnet', 'magnet:x', 'downloading', '2026-03-01 12:00:00', '2026-03-01 13:00:00')`); err != nil { t.Fatalf("seed: %v", err) } read := func() (string, string, sql.NullString) { var c, u string var sa sql.NullString if err := db.QueryRowx( `SELECT created_at, updated_at, source_added_at FROM download WHERE id='d1'`). Scan(&c, &u, &sa); err != nil { t.Fatalf("read: %v", err) } return c, u, sa } // Up 0008 → RFC 3339. if err := goose.UpTo(db.DB, "migrations", 8); err != nil { t.Fatalf("up to 8: %v", err) } if c, u, sa := read(); c != "2026-03-01T12:00:00Z" || u != "2026-03-01T13:00:00Z" || sa.Valid { t.Fatalf("после up: %q / %q / sa.Valid=%v, want RFC 3339 и NULL sa", c, u, sa.Valid) } // Down 0008 → формат с пробелом. if err := goose.DownTo(db.DB, "migrations", 7); err != nil { t.Fatalf("down to 7: %v", err) } if c, u, sa := read(); c != "2026-03-01 12:00:00" || u != "2026-03-01 13:00:00" || sa.Valid { t.Fatalf("после down: %q / %q / sa.Valid=%v, want формат с пробелом и NULL sa", c, u, sa.Valid) } }