package catalog_test import ( "context" "fmt" "log/slog" "path/filepath" "strings" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/store" ) func openStore(t *testing.T) *store.Store { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("открытие базы: %v", err) } t.Cleanup(func() { _ = st.Close() }) return st } func service(t *testing.T, st *store.Store) *catalog.Service { t.Helper() return catalog.New(st, slog.New(slog.DiscardHandler)) } // logged собирает записи лога: чекпоинт, который никто не перехватывает, ничем // не удерживается — его перевод на DEBUG или потеря атрибутов пройдут зелёным // гейтом, а владелец о смене рода не узнает. type logged struct { records []slog.Record } func (h *logged) Enabled(context.Context, slog.Level) bool { return true } func (h *logged) WithAttrs([]slog.Attr) slog.Handler { return h } func (h *logged) WithGroup(string) slog.Handler { return h } func (h *logged) Handle(_ context.Context, r slog.Record) error { h.records = append(h.records, r.Clone()) return nil } func (h *logged) find(msg string) (slog.Record, bool) { for _, r := range h.records { if r.Message == msg { return r, true } } return slog.Record{}, false } func attr(r slog.Record, key string) string { out := "" r.Attrs(func(a slog.Attr) bool { if a.Key == key { out = a.Value.String() return false } return true }) return out } func incoming(metric, layer, units string, at time.Time, v float64) store.IncomingPoint { return store.IncomingPoint{ Metric: metric, Layer: layer, Units: units, Point: store.Point{Start: at, End: at, Raw: qty(v)}, } } func merge(t *testing.T, st *store.Store, points []store.IncomingPoint) { t.Helper() _, err := st.Merge(context.Background(), store.Incoming{Points: points}, store.DeliveryRef{ID: "delivery"}) if err != nil { t.Fatalf("слияние: %v", err) } } // метрику кладём в оба слоя за n часов: часовая точка на границе часа, три // минутных внутри. `sum` задаёт, будет ли часовое значение суммой или средним. func fill(t *testing.T, st *store.Store, metric string, hours int, sum bool) { t.Helper() var points []store.IncomingPoint for i := range hours { h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour) coarse := 2.0 if sum { coarse = 6.0 } points = append(points, incoming(metric, "hour", "count", h, coarse)) for m, v := range []float64{1, 2, 3} { points = append(points, incoming(metric, "minute", "count", h.Add(time.Duration(m)*time.Minute), v)) } } merge(t, st, points) } func find(t *testing.T, metrics []catalog.Metric, name string) catalog.Metric { t.Helper() for _, m := range metrics { if m.Metric == name { return m } } t.Fatalf("метрики %q в каталоге нет", name) return catalog.Metric{} } func TestКаталогОтдаётРазрезыИИзмеренныйРод(t *testing.T) { t.Parallel() st := openStore(t) fill(t, st, "step_count", 5, true) fill(t, st, "heart_rate", 5, false) // Метрика, лежащая только в нижнем слое: слой в каталоге есть, рода нет. merge(t, st, []store.IncomingPoint{ incoming("sleep_analysis", "raw", "hr", time.Date(2026, 6, 1, 3, 7, 0, 0, time.UTC), 1), }) metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } steps := find(t, metrics, "step_count") if steps.Aggregation.Style != catalog.Cumulative { t.Errorf("step_count: род %v", steps.Aggregation.Style) } if len(steps.Units) != 1 || steps.Units[0] != "count" { t.Errorf("step_count: единицы %v", steps.Units) } if len(steps.Layers) != 2 { t.Errorf("step_count: слоёв %d, ждали 2", len(steps.Layers)) } hr := find(t, metrics, "heart_rate") if hr.Aggregation.Style != catalog.Instant { t.Errorf("heart_rate: род %v", hr.Aggregation.Style) } sleep := find(t, metrics, "sleep_analysis") if sleep.Aggregation.Style != catalog.Unknown { t.Errorf("sleep_analysis: род %v, ждали unknown", sleep.Aggregation.Style) } if sleep.Aggregation.Hours != 0 || sleep.Aggregation.FirstHour != nil { t.Errorf("sleep_analysis: основание %+v — сравнивать было нечего", sleep.Aggregation.Basis) } if len(sleep.Layers) != 1 || sleep.Layers[0].Layer != "raw" { t.Errorf("sleep_analysis: слои %+v", sleep.Layers) } } // Нижний слой в сверке не участвует: у метрики есть `raw` и `hour`, но нет // `minute` — рода быть не должно, сколько бы данных ни лежало в нижнем слое. func TestКаталогНеИзмеряетПоНижнемуСлою(t *testing.T) { t.Parallel() st := openStore(t) var points []store.IncomingPoint for i := range 5 { h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour) points = append(points, incoming("active_energy", "hour", "kJ", h, 6)) for m, v := range []float64{1, 2, 3} { points = append(points, incoming("active_energy", "raw", "kJ", h.Add(time.Duration(m)*time.Second), v)) } } merge(t, st, points) metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } m := find(t, metrics, "active_energy") if m.Aggregation.Style != catalog.Unknown { t.Errorf("род %v, ждали unknown", m.Aggregation.Style) } if m.Aggregation.Hours != 0 { t.Errorf("часов окна %d — нижний слой не имеет права попадать в сверку", m.Aggregation.Hours) } } func TestКаталогОграничиваетОкно(t *testing.T) { t.Parallel() st := openStore(t) fill(t, st, "step_count", catalog.Window+7, true) metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } m := find(t, metrics, "step_count") if m.Aggregation.Hours != catalog.Window { t.Errorf("часов окна %d, ждали %d", m.Aggregation.Hours, catalog.Window) } // Окно берёт САМЫЕ СВЕЖИЕ часы: старейший из сравненных обязан быть позже // первого часа истории. first := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) if m.Aggregation.FirstHour == nil || !m.Aggregation.FirstHour.After(first) { t.Errorf("начало окна %v — окно взяло не свежие часы", m.Aggregation.FirstHour) } } // Единицы, разошедшиеся между объектами, показываются множеством, а слой // остаётся одним элементом: клиент, читающий слои словарём, иначе потерял бы // половину диапазона молча. func TestКаталогПоказываетРасхождениеЕдиниц(t *testing.T) { t.Parallel() st := openStore(t) base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) merge(t, st, []store.IncomingPoint{ incoming("walking_running_distance", "minute", "km", base, 1), incoming("walking_running_distance", "minute", "m", base.Add(2*time.Hour), 2), }) metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } m := find(t, metrics, "walking_running_distance") if len(m.Units) != 2 { t.Errorf("единицы %v, ждали оба значения", m.Units) } if len(m.Layers) != 1 { t.Fatalf("слоёв %d, ждали один элемент на слой: %+v", len(m.Layers), m.Layers) } if m.Layers[0].Points != 2 { t.Errorf("точек в слое %d, ждали 2 — диапазоны обязаны объединиться", m.Layers[0].Points) } if !m.Layers[0].From.Equal(base) || !m.Layers[0].To.Equal(base.Add(2*time.Hour)) { t.Errorf("границы слоя %v..%v", m.Layers[0].From, m.Layers[0].To) } } func TestКаталогПустойВитриныПуст(t *testing.T) { t.Parallel() metrics, err := metricsOf(context.Background(), service(t, openStore(t))) if err != nil { t.Fatalf("каталог: %v", err) } if len(metrics) != 0 { t.Errorf("метрик %d, ждали ноль", len(metrics)) } } // Род нигде не хранится: новая доставка меняет его в следующем же ответе, без // перезапуска и без пересчёта чего бы то ни было. func TestКаталогПересчитываетРодНаКаждыйЗапрос(t *testing.T) { t.Parallel() st := openStore(t) svc := service(t, st) ctx := context.Background() fill(t, st, "step_count", 2, true) before, err := metricsOf(ctx, svc) if err != nil { t.Fatalf("каталог: %v", err) } if find(t, before, "step_count").Aggregation.Style != catalog.Unknown { t.Fatal("двух часов не хватает на вердикт — ждали unknown") } fill(t, st, "step_count", 5, true) after, err := metricsOf(ctx, svc) if err != nil { t.Fatalf("каталог: %v", err) } if got := find(t, after, "step_count").Aggregation.Style; got != catalog.Cumulative { t.Errorf("после доставки род %v, ждали cumulative", got) } } // Час объекта берётся из метки в теле доставки, а тело не наше. Без верхней // границы окна одна доставка с метками в будущем вытесняет всю настоящую // историю метрики и подменяет измеренный род: мгновенная объявлялась // накопительной при нуле противоречащих часов. func TestКаталогНеИзмеряетПоБудущимЧасам(t *testing.T) { t.Parallel() st := openStore(t) fill(t, st, "blood_oxygen_saturation", 6, false) // прошлое: среднее → instant // Будущее: столько же часов «суммой», сколько влезает в окно целиком. var future []store.IncomingPoint base := store.Now().Add(72 * time.Hour).Truncate(time.Hour) for i := range catalog.Window { h := base.Add(time.Duration(i) * time.Hour) future = append(future, incoming("blood_oxygen_saturation", "hour", "count", h, 6)) for m, v := range []float64{1, 2, 3} { future = append(future, incoming("blood_oxygen_saturation", "minute", "count", h.Add(time.Duration(m)*time.Minute), v)) } } merge(t, st, future) handler := &logged{} metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))) if err != nil { t.Fatalf("каталог: %v", err) } m := find(t, metrics, "blood_oxygen_saturation") if m.Aggregation.Style != catalog.Instant { t.Errorf("род %v: часы из будущего заняли окно и подменили измерение", m.Aggregation.Style) } if m.Aggregation.LastHour == nil || m.Aggregation.LastHour.After(store.Now().Add(time.Hour)) { t.Errorf("конец окна %v — окно ушло в будущее", m.Aggregation.LastHour) } if _, ok := handler.find("future data"); !ok { t.Error("данные из будущего есть, а предупреждения владельцу нет") } } // Единицы обеих сторон обязаны совпасть: минутный слой в count/min и часовой в // count/hour дают «часовое = 60 · среднее = сумма» в полном часе, то есть // уверенный ложный cumulative при нуле противоречащих часов. func TestКаталогНеСверяетСлоиРазныхЕдиниц(t *testing.T) { t.Parallel() st := openStore(t) var points []store.IncomingPoint for i := range 6 { h := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC).Add(time.Duration(i) * time.Hour) points = append(points, incoming("walking_speed", "hour", "count/hour", h, 6)) for m, v := range []float64{1, 2, 3} { points = append(points, incoming("walking_speed", "minute", "count/min", h.Add(time.Duration(m)*time.Minute), v)) } } merge(t, st, points) metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } m := find(t, metrics, "walking_speed") if m.Aggregation.Style != catalog.Unknown { t.Errorf("род %v: единицы слоёв разошлись, сравнивать было нечего", m.Aggregation.Style) } if m.Aggregation.Compared != 0 { t.Errorf("пригодных часов %d, ждали 0", m.Aggregation.Compared) } } // Пустое имя метрики — законное значение колонки. Каталог отвечает на вопрос // «что у тебя вообще есть», и терять на нём то, что лежит в витрине, нельзя. func TestКаталогПоказываетМетрикуСПустымИменем(t *testing.T) { t.Parallel() st := openStore(t) base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) merge(t, st, []store.IncomingPoint{ incoming("", "minute", "count", base, 1), incoming("step_count", "minute", "count", base, 2), }) metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } if len(metrics) != 2 { t.Fatalf("метрик в каталоге %d, ждали 2: %+v", len(metrics), metrics) } if metrics[0].Metric != "" || metrics[0].Layers[0].Points != 1 { t.Errorf("метрика с пустым именем потерялась: %+v", metrics[0]) } } func TestКаталогПишетПредупреждениеОПротиворечии(t *testing.T) { t.Parallel() st := openStore(t) long := strings.Repeat("метрика", 200) var points []store.IncomingPoint base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) for i := range 6 { h := base.Add(time.Duration(i) * time.Hour) coarse := 6.0 // сумма if i >= 4 { coarse = 2.0 // среднее — противоречие } points = append(points, incoming(long, "hour", "count", h, coarse)) for m, v := range []float64{1, 2, 3} { points = append(points, incoming(long, "minute", "count", h.Add(time.Duration(m)*time.Minute), v)) } } merge(t, st, points) handler := &logged{} metrics, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))) if err != nil { t.Fatalf("каталог: %v", err) } if find(t, metrics, long).Aggregation.Style != catalog.Unknown { t.Error("противоречие обязано гасить род") } rec, ok := handler.find("aggregation style conflict") if !ok { t.Fatal("противоречие есть, а предупреждения владельцу нет") } if rec.Level != slog.LevelWarn { t.Errorf("уровень %v, ждали WARN: адресат записи — владелец", rec.Level) } if got := attr(rec, "metric"); len(got) > 128 { t.Errorf("имя метрики в логе не обрезано: %d байт", len(got)) } if attr(rec, "conflicting") == "" { t.Error("в записи нет чисел основания — по ней нечего разбирать") } } // Число различных единиц у метрики равно числу её объектов: без потолка одна // запись каталога растёт вместе с витриной. func TestКаталогОграничиваетЧислоЕдиниц(t *testing.T) { t.Parallel() st := openStore(t) base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) var points []store.IncomingPoint for i := range 40 { points = append(points, incoming("step_count", "minute", fmt.Sprintf("unit-%02d", i), base.Add(time.Duration(i)*time.Hour), 1)) } merge(t, st, points) metrics, err := metricsOf(context.Background(), service(t, st)) if err != nil { t.Fatalf("каталог: %v", err) } m := find(t, metrics, "step_count") if len(m.Units) == 0 || len(m.Units) > 16 { t.Errorf("единиц в ответе %d — потолок не работает", len(m.Units)) } if len(m.Layers) != 1 { t.Errorf("слоёв %d, ждали один элемент на слой", len(m.Layers)) } } // Отказ хранилища логируется доменной границей один раз и уходит наверх: у // транспорта своей записи нет, он только переводит ошибку в ответ. func TestКаталогСообщаетОбОтказеХранилища(t *testing.T) { t.Parallel() st := openStore(t) if err := st.Close(); err != nil { t.Fatalf("закрытие базы: %v", err) } handler := &logged{} if _, err := metricsOf(context.Background(), catalog.New(st, slog.New(handler))); err == nil { t.Fatal("каталог на закрытой базе собрался") } rec, ok := handler.find("catalog failed") if !ok { t.Fatal("отказ не записан на доменной границе") } if rec.Level != slog.LevelError { t.Errorf("уровень %v, ждали ERROR: сбой хранилища адресован владельцу", rec.Level) } } // Отмена снаружи — «не сделано», а не «не выходит»: она не имеет права давать // владельцу ERROR, иначе единственный канал настоящих сбоев забивается // клиентами, оборвавшими запрос по своему тайм-ауту. func TestКаталогНеПутаетОтменуСоСбоем(t *testing.T) { t.Parallel() st := openStore(t) fill(t, st, "step_count", 3, true) ctx, cancel := context.WithCancel(context.Background()) cancel() handler := &logged{} if _, err := metricsOf(ctx, catalog.New(st, slog.New(handler))); err == nil { t.Fatal("каталог собрался на отменённом контексте") } if _, ok := handler.find("catalog failed"); ok { t.Error("отмена снаружи записана как сбой сервиса") } if _, ok := handler.find("catalog interrupted"); !ok { t.Error("отмена не отмечена вовсе — исход операции обязан быть виден") } } // metricsOf — список метрик каталога без версии витрины: версию проверяют // отдельные тесты, остальным нужен только состав ответа. func metricsOf(ctx context.Context, s *catalog.Service) ([]catalog.Metric, error) { snap, err := s.Metrics(ctx) return snap.Metrics, err } // Каталог подписывает свой ответ версией витрины: без неё транспорту нечего // поставить в `ETag`, и условный запрос не работает вовсе. func TestКаталогОтдаётсяСВерсиейВитрины(t *testing.T) { t.Parallel() st := openStore(t) snap, err := service(t, st).Metrics(context.Background()) if err != nil { t.Fatalf("каталог: %v", err) } if snap.Version == "" { t.Error("каталог собран на стоящей витрине и остался без версии") } } // Версия ответа каталога — это версия витрины ПЛЮС горизонт измерения. // // Утверждение прямое, потому что склейка теперь общая: её же зовёт маршрут // точек. Сломай её — и оба маршрута начнут подтверждать неизменность ответа, // чей род перевернулся ходом часов, а не коммитом. func TestВерсияКаталогаНесётГоризонт(t *testing.T) { st := openStore(t) ctx := context.Background() got, err := service(t, st).Version(ctx) if err != nil { t.Fatalf("Version: %v", err) } bare, err := st.StateVersion(ctx) if err != nil { t.Fatalf("StateVersion: %v", err) } if got == bare { t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл") } if want := catalog.Stamp(bare, catalog.Horizon()); got != want { t.Errorf("версия ответа %q, ожидалась %q", got, want) } }