package store_test import ( "context" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/store" ) func version(t *testing.T, st *store.Store) string { t.Helper() v, err := st.StateVersion(t.Context()) if err != nil { t.Fatalf("версия витрины: %v", err) } if v == "" { t.Fatal("версия витрины пуста") } return v } // Точка в витрину, чтобы у версии было от чего измениться. func writePoint(t *testing.T, st *store.Store, metric string, at time.Time) { t.Helper() _, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{ Metric: metric, Layer: "minute", Units: "count", Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)}, }}}, store.DeliveryRef{ID: "d"}) if err != nil { t.Fatalf("слияние: %v", err) } } // Главное свойство: без коммитов метка не двигается. На нём держится `304`. func TestВерсияНеМеняетсяБезЗаписи(t *testing.T) { t.Parallel() st := open(t) first := version(t, st) // Читающие запросы версию двигать не имеют права: иначе условный запрос не // сработал бы ни разу — каждый ответ каталога сам бы себя и обесценивал. if _, err := st.ReadCatalog(t.Context(), window(48, time.Now())); err != nil { t.Fatalf("каталог: %v", err) } if got := version(t, st); got != first { t.Errorf("версия сдвинулась без записи: %q → %q", first, got) } } // Запись из пула — то есть с ЧУЖОГО для щупа соединения — обязана быть видна. // Это ровно случай фоновой свёртки: она пишет, пока читающий маршрут отвечает. func TestВерсияМеняетсяПослеЗаписи(t *testing.T) { t.Parallel() st := open(t) before := version(t, st) writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z")) after := version(t, st) if after == before { t.Errorf("версия не изменилась после записи: %q", before) } if strings.SplitN(before, "-", 2)[0] != strings.SplitN(after, "-", 2)[0] { t.Errorf("поколение сменилось без пересоздания щупа: %q → %q", before, after) } } // Счётчик `data_version` после переоткрытия базы начинается заново, и одно и то // же значение до и после означало бы разные состояния витрины. Метку от этого // спасает поколение — проверяем на одном и том же файле. func TestВерсияНеПовторяетсяПослеПереоткрытия(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "healthlog.db") first, err := store.Open(path) if err != nil { t.Fatalf("открытие базы: %v", err) } before := version(t, first) writePoint(t, first, "step_count", ts(t, "2026-06-01T10:00:00Z")) if err := first.Close(); err != nil { t.Fatalf("закрытие базы: %v", err) } second, err := store.Open(path) if err != nil { t.Fatalf("повторное открытие базы: %v", err) } t.Cleanup(func() { _ = second.Close() }) if after := version(t, second); after == before { t.Errorf("версия совпала через переоткрытие: %q — клиент получил бы 304 на изменившиеся данные", before) } } // Версия читается конкурентно: `sql.Conn` одновременного использования не // допускает, и без защиты это гонка, а не редкий отказ. func TestВерсияЧитаетсяКонкурентно(t *testing.T) { t.Parallel() st := open(t) const n = 16 errs := make(chan error, n) for range n { go func() { _, err := st.StateVersion(context.Background()) errs <- err }() } for range n { if err := <-errs; err != nil { t.Fatalf("версия витрины: %v", err) } } } // Подпись ответа: пока витрина стоит, чтение подписывается версией. func TestVersionedReadПодписываетТихоеЧтение(t *testing.T) { t.Parallel() st := open(t) called := false v, err := st.VersionedRead(t.Context(), func(context.Context) error { called = true return nil }) if err != nil { t.Fatalf("чтение с версией: %v", err) } if !called { t.Fatal("чтение не выполнено") } if v == "" { t.Error("тихое чтение осталось без версии") } } // Единственная ветка, ради которой проба делается дважды: витрина изменилась, // пока ответ собирался. Метки быть не должно — иначе два разных ответа уехали // бы под одной, а подписать снимок версией, снятой ПОСЛЕ него, значило бы // запереть клиента на устаревшем ответе навсегда. func TestVersionedReadНеПодписываетИзменившеесяЧтение(t *testing.T) { t.Parallel() st := open(t) v, err := st.VersionedRead(t.Context(), func(ctx context.Context) error { writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z")) return nil }) if err != nil { t.Fatalf("чтение с версией: %v", err) } if v != "" { t.Errorf("ответ подписан версией %q, хотя витрина изменилась при сборке", v) } } // Отказ самого чтения версией не подменяется: это отказ операции, и он обязан // дойти до вызывающего. func TestVersionedReadВозвращаетОтказЧтения(t *testing.T) { t.Parallel() st := open(t) want := errors.New("чтение не вышло") if _, err := st.VersionedRead(t.Context(), func(context.Context) error { return want }); !errors.Is(err, want) { t.Errorf("ошибка %v, ждали %v", err, want) } } // После закрытия хранилища щуп не воскресает: соединение, открытое позже // закрытия пула, осталось бы последним, и SQLite не сделал бы финальный // чекпойнт. func TestВерсияПослеЗакрытияОтказывает(t *testing.T) { t.Parallel() st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } if err := st.Close(); err != nil { t.Fatalf("закрытие базы: %v", err) } if _, err := st.StateVersion(context.Background()); !errors.Is(err, store.ErrClosed) { t.Errorf("ошибка %v, ждали %v", err, store.ErrClosed) } } // Закрытие хранилища обязано оставить базу без журнала: финальный чекпойнт // делает SQLite при закрытии ПОСЛЕДНЕГО соединения, а щуп его переживает. // Незакрытый щуп означал бы `-wal` рядом с базой — и подмену базы пересборкой // без хвоста записей. func TestЗакрытиеНеОставляетЖурнала(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "healthlog.db") st, err := store.Open(path) if err != nil { t.Fatalf("открытие базы: %v", err) } if _, err := st.StateVersion(t.Context()); err != nil { t.Fatalf("версия витрины: %v", err) } writePoint(t, st, "step_count", ts(t, "2026-06-01T10:00:00Z")) if err := st.Close(); err != nil { t.Fatalf("закрытие базы: %v", err) } if _, err := os.Stat(path + "-wal"); !os.IsNotExist(err) { t.Errorf("рядом с базой остался журнал: %v", err) } }