Files
av 29ca8d415c httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
2026-08-04 18:46:45 +03:00

301 lines
12 KiB
Go

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)
}
}