- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
196 lines
7.5 KiB
Go
196 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|