package points_test import ( "context" "encoding/json" "log/slog" "path/filepath" "sync" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/catalog" "git.vakhrushev.me/av/healthlog/internal/points" "git.vakhrushev.me/av/healthlog/internal/store" ) func service(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store) { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { _ = st.Close() }) if len(in) > 0 { if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil { t.Fatalf("слияние: %v", err) } } return points.New(st, slog.New(slog.DiscardHandler)), st } func hourAt(hh int) time.Time { return time.Date(2026, 6, 1, hh, 0, 0, 0, time.UTC) } func in(metric, layer string, at time.Time) store.IncomingPoint { return store.IncomingPoint{ Metric: metric, Layer: layer, Units: "count", Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)}, } } // Версия ответа несёт ГОРИЗОНТ измерения, а не только версию витрины. // // Без горизонта метка не меняется, когда час из будущего въезжает в окно сам, // ходом часов и без единого коммита, — и соседняя задача условного запроса // подтвердит `304` на ответе, чей род уже перевернулся. Путь проект уже строил // и закрывал у каталога; здесь он закрывается тем же механизмом. func TestВерсияОтветаНесётГоризонт(t *testing.T) { svc, st := service(t, in("m", "raw", hourAt(9))) ctx := context.Background() got, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}) if err != nil { t.Fatalf("Series: %v", err) } if got.Version == "" { t.Fatal("ответ без версии — подписывать условный запрос нечем") } bare, err := st.StateVersion(ctx) if err != nil { t.Fatalf("StateVersion: %v", err) } if got.Version == bare { t.Error("версия ответа равна версии витрины — горизонт в неё не вошёл") } if want := catalog.Stamp(bare, catalog.Horizon()); got.Version != want { t.Errorf("версия ответа %q, ожидалась %q", got.Version, want) } } // Слой выбирается тем же правилом, что проверено на охватах, но уже через // хранилище: «вес за год» обязан вернуть длинный слой, а не плотный короткий. func TestРядБерётСлойСНаибольшимОхватом(t *testing.T) { svc, _ := service(t, in("body_mass", "raw", hourAt(9)), in("body_mass", "raw", hourAt(10)), in("body_mass", "hour", hourAt(1)), in("body_mass", "hour", hourAt(20)), ) got, err := svc.Series(context.Background(), points.Request{ Metric: "body_mass", From: hourAt(0), To: hourAt(23), }) if err != nil { t.Fatalf("Series: %v", err) } if got.Layer != "hour" { t.Errorf("слой %q, ожидался hour: нижний слой охватывает меньше", got.Layer) } if len(got.Points) != 2 { t.Errorf("точек %d, ожидалось 2", len(got.Points)) } } // Явно запрошенный слой уезжает в ответе даже пустым: клиент, спросивший разрез // поимённо, обязан отличать «за период этого разреза нет» от «параметр // проигнорирован». Слой, который выбирала система и выбрать не смогла, — пустой. func TestРядРазличаетПустойЯвныйСлойИОтсутствиеВыбора(t *testing.T) { svc, _ := service(t, in("m", "raw", hourAt(9))) ctx := context.Background() explicit, err := svc.Series(ctx, points.Request{ Metric: "m", From: hourAt(0), To: hourAt(23), Layer: "minute", }) if err != nil { t.Fatalf("Series: %v", err) } if explicit.Layer != "minute" { t.Errorf("явный слой %q, ожидался minute", explicit.Layer) } if len(explicit.Points) != 0 { t.Errorf("точек %d, ожидалось 0", len(explicit.Points)) } chosen, err := svc.Series(ctx, points.Request{ Metric: "нет такой", From: hourAt(0), To: hourAt(23), }) if err != nil { t.Fatalf("Series: %v", err) } if chosen.Layer != "" { t.Errorf("слой %q, ожидался пустой: выбирать было не из чего", chosen.Layer) } } // Отмена запроса клиентом — обстоятельство, а не отказ: ответ не собирается, но // и ERROR владельцу не пишется. Уровень проверяет тест транспорта; здесь — // что отмена вообще доезжает до драйвера и не игнорируется. func TestРядУважаетОтменуКонтекста(t *testing.T) { svc, _ := service(t, in("m", "raw", hourAt(9))) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil { t.Error("отменённый запрос собрал ответ — context до драйвера не доехал") } } // Отказ хранилища доезжает до вызывающего отказом, а не пустым рядом: маршрут // обязан ответить 500, а не «данных нет». Отказ при этом НЕ транзиентный — // значит уходит владельцу уровнем ERROR, а не тонет в DEBUG. func TestРядНаЗакрытомХранилищеОтказывает(t *testing.T) { svc, st := service(t, in("m", "raw", hourAt(9))) if err := st.Close(); err != nil { t.Fatalf("закрытие: %v", err) } _, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}) if err == nil { t.Fatal("закрытое хранилище отдало ряд") } if store.Transient(err) { t.Error("отказ закрытого хранилища объявлен обстоятельством — владелец о нём не узнает") } } // levels — slog.Handler, копящий уровень и сообщение. Значений атрибутов не // хранит: проверяется адресат записи, а данные о здоровье в тесты тащить // незачем. type levels struct { mu sync.Mutex seen []slog.Record } func (l *levels) Enabled(context.Context, slog.Level) bool { return true } func (l *levels) WithAttrs([]slog.Attr) slog.Handler { return l } func (l *levels) WithGroup(string) slog.Handler { return l } func (l *levels) Handle(_ context.Context, r slog.Record) error { l.mu.Lock() defer l.mu.Unlock() l.seen = append(l.seen, r.Clone()) return nil } func (l *levels) levelOf(msg string) (slog.Level, bool) { l.mu.Lock() defer l.mu.Unlock() for _, r := range l.seen { if r.Message == msg { return r.Level, true } } return 0, false } func (l *levels) count(level slog.Level) int { l.mu.Lock() defer l.mu.Unlock() n := 0 for _, r := range l.seen { if r.Level == level { n++ } } return n } func loggedService(t *testing.T, in ...store.IncomingPoint) (*points.Service, *store.Store, *levels) { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "healthlog.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { _ = st.Close() }) if len(in) > 0 { if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil { t.Fatalf("слияние: %v", err) } } seen := &levels{} return points.New(st, slog.New(seen)), st, seen } // Отмена клиентом — обстоятельство, а не отказ, и уровень записи это отражает. // // Утверждение прямое, потому что иначе оно не держится ничем: смена // классификации не даёт ни ошибки компиляции, ни красного теста. Агент // опрашивает маршрут по расписанию, и `ERROR` на каждый его тайм-аут забил бы // единственный канал, по которому владелец видит настоящий сбой хранилища. func TestОтменаЗапросаПишетсяDEBUG(t *testing.T) { svc, _, seen := loggedService(t, in("m", "raw", hourAt(9))) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := svc.Series(ctx, points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil { t.Fatal("отменённый запрос собрал ответ") } level, ok := seen.levelOf("series interrupted") if !ok { t.Fatal("отмена не оставила чекпоинта — исход не наблюдаем") } if level != slog.LevelDebug { t.Errorf("уровень %s, ожидался DEBUG", level) } if n := seen.count(slog.LevelError); n != 0 { t.Errorf("записей ERROR %d, ожидалось 0: отмена клиента — не сбой хранилища", n) } } // Настоящий отказ хранилища доходит до владельца уровнем ERROR. func TestОтказХранилищаПишетсяERROR(t *testing.T) { svc, st, seen := loggedService(t, in("m", "raw", hourAt(9))) if err := st.Close(); err != nil { t.Fatalf("закрытие: %v", err) } if _, err := svc.Series(context.Background(), points.Request{Metric: "m", From: hourAt(0), To: hourAt(23)}); err == nil { t.Fatal("закрытое хранилище собрало ответ") } level, ok := seen.levelOf("series failed") if !ok { t.Fatal("отказ не оставил чекпоинта") } if level != slog.LevelError { t.Errorf("уровень %s, ожидался ERROR", level) } } // Предупреждения измерения — привилегия каталога, и маршрут точек их НЕ // повторяет: агент опрашивает по расписанию, и WARN на каждый опрос обесценил // бы уровень ровно так же, как обесценила бы его строка на каждый `304`. func TestМаршрутТочекНеПовторяетПредупрежденияИзмерения(t *testing.T) { // Метрика с противоречащим родом: часть часов сходится с суммой, часть — со // средним. Каталог на таком входе пишет WARN. var seed []store.IncomingPoint for h := range 8 { hour := hourAt(h) coarse := "30" if h%2 == 0 { coarse = "15" // среднее двух минутных значений 10 и 20 } seed = append(seed, store.IncomingPoint{Metric: "mixed", Layer: "hour", Units: "kJ", Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":` + coarse + `}`)}}, store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ", Point: store.Point{Start: hour, End: hour, Raw: json.RawMessage(`{"qty":10}`)}}, store.IncomingPoint{Metric: "mixed", Layer: "minute", Units: "kJ", Point: store.Point{Start: hour.Add(time.Minute), End: hour.Add(time.Minute), Raw: json.RawMessage(`{"qty":20}`)}}, ) } svc, _, seen := loggedService(t, seed...) for range 3 { if _, err := svc.Series(context.Background(), points.Request{ Metric: "mixed", From: hourAt(0), To: hourAt(23), }); err != nil { t.Fatalf("Series: %v", err) } } if n := seen.count(slog.LevelWarn); n != 0 { t.Errorf("маршрут точек написал %d предупреждений — опрос по расписанию обесценит уровень", n) } }