package store_test import ( "context" "database/sql" "errors" "path/filepath" "testing" "time" _ "modernc.org/sqlite" // чистый Go-драйвер SQLite, без cgo "git.vakhrushev.me/av/healthlog/internal/store" ) // Пересборка читает рабочую базу, пока в неё может писать сервис. Обычное // открытие накатывает миграции безусловно, а миграции меняют и данные — та, что // ввела частичный разбор, переписала parse_status у всех строк. Значит утилите // нужен путь чтения, который базу не трогает. func TestOpenForReadНеПишетВБазу(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "healthlog.db") seed(t, path) ro, err := store.OpenForRead(path) if err != nil { t.Fatalf("OpenForRead: %v", err) } defer func() { _ = ro.Close() }() ctx := context.Background() got, err := ro.ListDeliveries(ctx) if err != nil { t.Fatalf("ListDeliveries: %v", err) } if len(got) != 2 { t.Fatalf("доставок %d, ожидалось 2", len(got)) } // Порядок журнала — (received_at, id), тот же, в котором проигрывает // пересборка. if got[0].ID != "01hzzzzzzzzzzzzzzzzzzzzzz1" || got[1].ID != "01hzzzzzzzzzzzzzzzzzzzzzz0" { t.Errorf("порядок %q, %q — не по времени приёма", got[0].ID, got[1].ID) } // Заголовки переносятся дословно: восстановить их неоткуда, в архиве их нет. if got[0].Headers != `{"x-test":["1"]}` { t.Errorf("заголовки %q не дошли дословно", got[0].Headers) } if err := ro.CreateDelivery(ctx, store.Delivery{ ID: "01hzzzzzzzzzzzzzzzzzzzzzz2", ReceivedAt: store.Now(), Bytes: 1, SHA256: "-", RawPath: "x", ParseStatus: store.ParsePending, }); err == nil { t.Error("запись в базу, открытую на чтение, удалась") } } // Расхождение версии схемы — отказ, а не повод мигрировать: иначе свежий бинарь // молча меняет схему под работающим старым сервисом. func TestOpenForReadОтвергаетЧужуюВерсиюСхемы(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "healthlog.db") seed(t, path) // Откатываем учёт миграций мимо store: имитируем базу, к которой бинарь // новее. db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatalf("sql.Open: %v", err) } _, err = db.Exec(`DELETE FROM goose_db_version WHERE version_id = (SELECT max(version_id) FROM goose_db_version)`) if err != nil { t.Fatalf("откат версии: %v", err) } _ = db.Close() if _, err := store.OpenForRead(path); !errors.Is(err, store.ErrSchemaMismatch) { t.Errorf("OpenForRead дал %v, ожидался ErrSchemaMismatch", err) } } func seed(t *testing.T, path string) { t.Helper() st, err := store.Open(path) if err != nil { t.Fatalf("Open: %v", err) } defer func() { _ = st.Close() }() base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) // Второй идентификатор меньше первого, а приехал он раньше: так проверяется, // что порядок берётся из времени приёма, а не из имени. rows := []store.Delivery{ {ID: "01hzzzzzzzzzzzzzzzzzzzzzz1", ReceivedAt: base}, {ID: "01hzzzzzzzzzzzzzzzzzzzzzz0", ReceivedAt: base.Add(time.Second)}, } for _, d := range rows { d.Bytes = 1 d.SHA256 = "-" d.RawPath = d.ID + ".json.gz" d.ParseStatus = store.ParsePending d.Headers = `{"x-test":["1"]}` if err := st.CreateDelivery(context.Background(), d); err != nil { t.Fatalf("CreateDelivery: %v", err) } } } // Откат бинаря поверх новой схемы обязан отказывать, а не стартовать молча: // старый бинарь незнакомые секции игнорирует и доставки за окно отката помечает // разобранными — ничто не намекает, что для этого окна нужна пересборка. Класс // «молчание», и после ретеншена тел окно становится невосстановимым. func TestOpenОтвергаетСхемуИзБудущего(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "healthlog.db") seed(t, path) db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatalf("sql.Open: %v", err) } if _, err := db.Exec( `INSERT INTO goose_db_version (version_id, is_applied, tstamp) VALUES (99, 1, datetime('now'))`); err != nil { t.Fatalf("вставка версии из будущего: %v", err) } _ = db.Close() st, err := store.Open(path) if err == nil { _ = st.Close() t.Fatal("Open молча открыл базу со схемой, которой бинарь не знает") } if !errors.Is(err, store.ErrSchemaMismatch) { t.Errorf("Open дал %v, ожидался ErrSchemaMismatch", err) } } // Версия базы НИЖЕ версии бинаря отказом быть не должна: ради этого случая // миграции и существуют. Асимметрия только у Open — OpenForRead строг. func TestOpenНоваяБазаМигрирует(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "fresh.db") st, err := store.Open(path) if err != nil { t.Fatalf("Open новой базы: %v", err) } if err := st.Close(); err != nil { t.Fatalf("Close: %v", err) } // Повторное открытие уже мигрированной базы тоже проходит: current == target. st2, err := store.Open(path) if err != nil { t.Fatalf("повторный Open: %v", err) } _ = st2.Close() } // База без журнала миграций нашей не является, и сказать это надо прямо и // сразу. Через goose такой вопрос стоил бы трёх секунд повторов и ответа // «attempt to write a readonly database» — то есть оператор, спросивший про // версию схемы, получил бы ответ про права на файл. func TestOpenForReadЧужаяБазаОтвергаетсяБыстро(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "alien.db") db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatalf("sql.Open: %v", err) } if _, err := db.Exec(`CREATE TABLE t (a INTEGER)`); err != nil { t.Fatalf("создание чужой таблицы: %v", err) } _ = db.Close() start := time.Now() st, err := store.OpenForRead(path) if err == nil { _ = st.Close() t.Fatal("чужая база открылась на чтение") } if !errors.Is(err, store.ErrSchemaMismatch) { t.Errorf("OpenForRead дал %v, ожидался ErrSchemaMismatch", err) } // Проверяется свойство «отказ не идёт через повторы записи», а не // конкретная скорость: повторы у goose — три по секунде. if d := time.Since(start); d > time.Second { t.Errorf("отказ занял %v — путь идёт через попытки записи", d) } }