Каталог разрезов и измеренный род агрегации
- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
func getCatalog(t *testing.T, h http.Handler, auth string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Пустая витрина отдаёт `[]`, а не `null`. Сравниваются БАЙТЫ: nil-срез
|
||||
// сериализуется в `null`, и тест, сличающий разобранные структуры, этого не
|
||||
// увидел бы — а клиент увидел бы сразу.
|
||||
func TestКаталогПустойВитриныОтдаётПустойСписок(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
rec := getCatalog(t, h, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != `{"metrics":[]}` {
|
||||
t.Errorf("тело %q, ждали {\"metrics\":[]}", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
|
||||
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
|
||||
func TestКаталогНеизмеренноеОкноОтдаётNull(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
_, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{
|
||||
Metric: "vo2_max", Layer: "raw", Units: "ml/(kg·min)",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":42}`)},
|
||||
}}}, store.DeliveryRef{ID: "d"})
|
||||
if err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
body := getCatalog(t, h, "").Body.String()
|
||||
for _, want := range []string{`"style":"unknown"`, `"first_hour":null`, `"last_hour":null`, `"hours":0`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("в ответе нет %s: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestКаталогТребуетТокенЧтения(t *testing.T) {
|
||||
write := []string{"write-token"}
|
||||
read := []string{"read-token"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
auth string
|
||||
want int
|
||||
}{
|
||||
{"без заголовка", "", http.StatusUnauthorized},
|
||||
{"токен приёма", "Bearer write-token", http.StatusUnauthorized},
|
||||
{"голое значение без схемы", "read-token", http.StatusUnauthorized},
|
||||
{"чужой токен", "Bearer nope", http.StatusUnauthorized},
|
||||
{"токен чтения", "Bearer read-token", http.StatusOK},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, write, read)
|
||||
|
||||
rec := getCatalog(t, h, c.auth)
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("статус %d, ждали %d (тело %s)", rec.Code, c.want, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой список токенов чтения = проверка выключена. Это симметрия с приёмом, и
|
||||
// цена её названа в конфиге: открытое чтение — выгрузка истории здоровья.
|
||||
func TestКаталогБезТокеновОтдаётся(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, []string{"write-token"}, nil)
|
||||
|
||||
if rec := getCatalog(t, h, ""); rec.Code != http.StatusOK {
|
||||
t.Errorf("статус %d, ждали 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Токен чтения — такой же секрет, как токен приёма: посланный на маршрут приёма
|
||||
// заголовком с произвольным именем, он не имеет права осесть в учёте доставки.
|
||||
func TestТокенЧтенияНеОседаетВУчётеДоставки(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, []string{"read-token"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(samplePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Whatever", "read-token")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d", rec.Code)
|
||||
}
|
||||
|
||||
d, err := st.LastDelivery(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("доставка: %v", err)
|
||||
}
|
||||
if strings.Contains(d.Headers, "read-token") {
|
||||
t.Errorf("токен чтения сохранён в заголовках доставки: %s", d.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
// Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками.
|
||||
// Wire-форма каталога — это доменные структуры с json-тегами, и переименование
|
||||
// поля меняет публичный контракт без единого касания транспорта; страж у него
|
||||
// один — этот литерал.
|
||||
func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
var points []store.IncomingPoint
|
||||
for i := range 4 {
|
||||
at := base.Add(time.Duration(i) * time.Hour)
|
||||
points = append(points, store.IncomingPoint{
|
||||
Metric: "step_count", Layer: "hour", Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":6}`)},
|
||||
})
|
||||
for m, v := range []string{`{"qty":1}`, `{"qty":2}`, `{"qty":3}`} {
|
||||
ts := at.Add(time.Duration(m) * time.Minute)
|
||||
points = append(points, store.IncomingPoint{
|
||||
Metric: "step_count", Layer: "minute", Units: "count",
|
||||
Point: store.Point{Start: ts, End: ts, Raw: json.RawMessage(v)},
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
|
||||
store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
want := `{"metrics":[{"metric":"step_count","units":["count"],` +
|
||||
`"aggregation":{"style":"cumulative","hours":4,"compared":4,"agreeing":4,` +
|
||||
`"conflicting":0,"first_hour":"2026-06-01T00:00:00Z","last_hour":"2026-06-01T03:00:00Z"},` +
|
||||
`"layers":[{"layer":"hour","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:00:00Z","points":4},` +
|
||||
`{"layer":"minute","from":"2026-06-01T00:00:00Z","to":"2026-06-01T03:02:00Z","points":12}]}]}`
|
||||
|
||||
if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want {
|
||||
t.Errorf("форма ответа изменилась:\n получили %s\n ждали %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Два запроса подряд на неизменившейся витрине совпадают побайтово: порядок
|
||||
// метрик и слоёв держится `ORDER BY` в чужом пакете, и снятие сортировки
|
||||
// «раз индекс и так отсортирован» проявилось бы у клиента, а не в тестах.
|
||||
func TestКаталогПовторяетсяПобайтово(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
base := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
var points []store.IncomingPoint
|
||||
for i, metric := range []string{"step_count", "heart_rate", "active_energy"} {
|
||||
for _, layer := range []string{"minute", "hour", "raw"} {
|
||||
at := base.Add(time.Duration(i) * time.Hour)
|
||||
points = append(points, store.IncomingPoint{
|
||||
Metric: metric, Layer: layer, Units: "count",
|
||||
Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)},
|
||||
})
|
||||
}
|
||||
}
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: points},
|
||||
store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
first := getCatalog(t, h, "").Body.String()
|
||||
second := getCatalog(t, h, "").Body.String()
|
||||
if first != second {
|
||||
t.Errorf("два ответа на неизменившейся витрине разошлись:\n %s\n %s", first, second)
|
||||
}
|
||||
if !strings.Contains(first, `"metric":"active_energy"`) {
|
||||
t.Fatalf("в ответе нет ожидаемых метрик: %s", first)
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки
|
||||
// наружу не уходит — в нём имена колонок и форма запроса.
|
||||
func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие базы: %v", err)
|
||||
}
|
||||
|
||||
rec := getCatalog(t, h, "")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("статус %d, ждали 500", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); strings.Contains(body, "sql") || strings.Contains(body, "bucket") {
|
||||
t.Errorf("наружу уехали внутренности: %s", body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user