httpapi: точки метрики за период отдаются одним запросом
- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user