Files
jellybit/internal/store/migration_test.go
T
avandClaude Opus 4.8 5d5456fa68 Хранение времени: RFC 3339 (UTC) + таймзона отображения в конфиге
Метки времени в SQLite переведены с формата datetime('now')
(«2006-01-02 15:04:05») на RFC 3339 всегда-UTC («2006-01-02T15:04:05Z»):
самоописываемое хранилище (зона в значении), валидный ISO 8601, единый
формат с логами. Фиксированная ширина сохраняет лексикографическую
сортировку TEXT = хронологию (COALESCE(source_added_at, created_at)).

- Единая точка генерации времени в Go: store.Now()/FormatTime; DEFAULT
  (datetime('now')) снят со всех колонок — время всегда пишет приложение
  (зеркально ident.NewID для id), fail-loud при забытой вставке (NOT NULL).
  Все INSERT-сайты в store передают created_at/updated_at явно.
- Миграция 0008 (rebuild 7 таблиц без DEFAULT + backfill strftime, FK/PK/
  индексы сохранены байт-в-байт по образцу 0006); симметричная down.
- Новая секция конфига [general] с полем timezone (дефолт UTC) — зона
  ОТОБРАЖЕНИЯ в веб-UI; хранение остаётся UTC. Жёсткая валидация зоны на
  старте; zoneinfo встроен (time/tzdata), заменён зашитый Europe/Moscow.
- Тесты: round-trip миграции (up/down, NULL source_added_at), валидация
  зоны, сдвиг даты по зоне; обновлены фикстуры и TestUlidMigration.
- Docs: конвенции database/config, ER-схема; спека web-ui (таймзона).

OpenSpec change time-storage-rfc3339 (заархивирован).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:32:07 +03:00

201 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}