- род метрики выводится сверкой минутного слоя с часовым: часовое значение сходится с суммой минутных — накопительная, со средним — мгновенная, иначе `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки, 31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль - `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и род вместе с основанием измерения; род нигде не хранится — он функция витрины, а витрина функция журнала, устаревать в нём нечему - миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам, не разжимая содержимое объектов
212 lines
8.8 KiB
Go
212 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|