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,677 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||||
)
|
||||
|
||||
// pointsAt — фиксированное «когда» витрины тестов: 1 июня 2026 года.
|
||||
//
|
||||
// Час выбран В ПРОШЛОМ и жёстко, а не относительно `time.Now()`, потому что
|
||||
// окно измерения ограничено горизонтом «сейчас плюс час»: фикстура,
|
||||
// построенная от текущего времени, при переходе через границу часа дала бы
|
||||
// другой набор пригодных часов — и другое тело ответа. Класс уже ловили
|
||||
// (docs/review.md, запись 2026-08-03).
|
||||
func pointsAt(hh, mm int) time.Time {
|
||||
return time.Date(2026, 6, 1, hh, mm, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func mergePoints(t *testing.T, st *store.Store, in ...store.IncomingPoint) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := st.Merge(context.Background(), store.Incoming{Points: in}, store.DeliveryRef{ID: "d"}); err != nil {
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func pointAt(metric, layer, units string, start, end time.Time, raw string) store.IncomingPoint {
|
||||
return store.IncomingPoint{
|
||||
Metric: metric, Layer: layer, Units: units,
|
||||
Point: store.Point{Start: start, End: end, Raw: json.RawMessage(raw)},
|
||||
}
|
||||
}
|
||||
|
||||
func getPoints(t *testing.T, h http.Handler, metric, query, auth string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/"+url.PathEscape(metric)+"?"+query, nil)
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
const dayWindow = "from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z"
|
||||
|
||||
// ФОРМА ОТВЕТА ЦЕЛИКОМ, БАЙТАМИ. Детектор изменения публичного контракта: он
|
||||
// краснеет в момент правки формы, а не у потребителя.
|
||||
//
|
||||
// Ни одно поле литерала не зависит от хода часов: витрина фиксирована, часы
|
||||
// лежат в прошлом, окно измерения на этих данных пусто. Утверждается заодно
|
||||
// К2 — `layer`, `aggregation` и `last_hour` присутствуют, — и правило «пустая
|
||||
// коллекция это `[]`, отсутствующее значение это `null`».
|
||||
func TestТочкиФормаОтветаБайтами(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
mergePoints(t, st,
|
||||
pointAt("body_mass", "raw", "kg", pointsAt(9, 30), pointsAt(9, 30), `{"qty":72.5,"date":"2026-06-01 12:30:00 +0300"}`),
|
||||
)
|
||||
|
||||
want := `{"metric":"body_mass","from":"2026-06-01T00:00:00Z","to":"2026-06-02T00:00:00Z",` +
|
||||
`"layer":"raw","bucket":null,` +
|
||||
`"aggregation":{"style":"unknown","applicable":false,"last_hour":null},` +
|
||||
`"points":[{"ts":"2026-06-01T09:30:00Z","ts_end":"2026-06-01T09:30:00Z","tz_offset":0,` +
|
||||
`"units":"kg","values":{"qty":72.5,"date":"2026-06-01 12:30:00 +0300"}}]}`
|
||||
|
||||
rec := getPoints(t, h, "body_mass", dayWindow, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != want {
|
||||
t.Errorf("форма ответа точек изменилась:\n получили %s\n ждали %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// К3: род не измерен — сказано словом, а не молчанием, и свёртка не предложена.
|
||||
// Проверяется через разбор, а не подстрокой: подстрока `"style"` осталась бы на
|
||||
// месте и у ответа, объявившего род.
|
||||
func TestТочкиНеизмеренныйРодНазванСловом(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
mergePoints(t, st,
|
||||
pointAt("six_minute_walking_test_distance", "minute", "m", pointsAt(9, 0), pointsAt(9, 0), `{"qty":420}`),
|
||||
)
|
||||
|
||||
var got struct {
|
||||
Aggregation struct {
|
||||
Style string `json:"style"`
|
||||
Applicable bool `json:"applicable"`
|
||||
LastHour *string `json:"last_hour"`
|
||||
} `json:"aggregation"`
|
||||
Points []json.RawMessage `json:"points"`
|
||||
}
|
||||
decodeBody(t, getPoints(t, h, "six_minute_walking_test_distance", dayWindow, ""), &got)
|
||||
|
||||
if got.Aggregation.Style != "unknown" {
|
||||
t.Errorf("род %q, ожидался unknown", got.Aggregation.Style)
|
||||
}
|
||||
if got.Aggregation.Applicable {
|
||||
t.Error("неизмеренный род объявлен применимым — потребителю предложено свернуть по неизвестному")
|
||||
}
|
||||
if got.Aggregation.LastHour != nil {
|
||||
t.Errorf("граница окна %v, ожидался null", *got.Aggregation.LastHour)
|
||||
}
|
||||
if len(got.Points) != 1 {
|
||||
t.Errorf("точек %d, ожидалась 1: род неизвестен — точки отдаются как есть", len(got.Points))
|
||||
}
|
||||
}
|
||||
|
||||
// Измеренный род едет вместе с границей окна и с применимостью. Данные —
|
||||
// минутный и часовой слои одной метрики, сходящиеся суммой: ровно тот вход, на
|
||||
// котором каталог объявляет `cumulative`.
|
||||
func TestТочкиИзмеренныйРодЕдетСГраницейОкна(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
var in []store.IncomingPoint
|
||||
for hh := range catalogAgreeingHours {
|
||||
hour := pointsAt(hh, 0)
|
||||
// Часовое значение равно сумме минутных, и сумма отличима от среднего.
|
||||
in = append(in,
|
||||
pointAt("active_energy", "hour", "kJ", hour, hour, `{"qty":30}`),
|
||||
pointAt("active_energy", "minute", "kJ", hour, hour, `{"qty":10}`),
|
||||
pointAt("active_energy", "minute", "kJ", hour.Add(time.Minute), hour.Add(time.Minute), `{"qty":20}`),
|
||||
)
|
||||
}
|
||||
mergePoints(t, st, in...)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
wantLayer string
|
||||
wantApplicable bool
|
||||
}{
|
||||
{"часовой слой", dayWindow + "&layer=hour", "hour", true},
|
||||
{"минутный слой", dayWindow + "&layer=minute", "minute", true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got struct {
|
||||
Layer *string `json:"layer"`
|
||||
Aggregation struct {
|
||||
Style string `json:"style"`
|
||||
Applicable bool `json:"applicable"`
|
||||
LastHour *string `json:"last_hour"`
|
||||
} `json:"aggregation"`
|
||||
}
|
||||
decodeBody(t, getPoints(t, h, "active_energy", c.query, ""), &got)
|
||||
|
||||
if got.Aggregation.Style != "cumulative" {
|
||||
t.Fatalf("род %q, ожидался cumulative", got.Aggregation.Style)
|
||||
}
|
||||
if got.Layer == nil || *got.Layer != c.wantLayer {
|
||||
t.Errorf("слой %v, ожидался %q", got.Layer, c.wantLayer)
|
||||
}
|
||||
if got.Aggregation.Applicable != c.wantApplicable {
|
||||
t.Errorf("применимость %v, ожидалась %v", got.Aggregation.Applicable, c.wantApplicable)
|
||||
}
|
||||
// Граница окна — ярлык часа, а не конец периода и не «сейчас».
|
||||
if got.Aggregation.LastHour == nil {
|
||||
t.Fatal("граница окна измерения null при измеренном роде")
|
||||
}
|
||||
if want := "2026-06-01T13:00:00Z"; *got.Aggregation.LastHour != want {
|
||||
t.Errorf("граница окна %q, ожидалась %q", *got.Aggregation.LastHour, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// catalogAgreeingHours — сколько согласных часов кладётся в фикстуру. Больше
|
||||
// порога каталога, и число названо здесь, а не подобрано в теле теста.
|
||||
const catalogAgreeingHours = 14
|
||||
|
||||
// Накопительная метрика на нижнем слое HAE объявляется НЕПРИМЕНИМОЙ: нижний
|
||||
// слой это интерполяция, а не сэмплы, и сумма по нему завышает втрое. Без этого
|
||||
// поля конверт приглашает агента сложить её самому.
|
||||
func TestТочкиНакопительнаяМетрикаНаНижнемСлоеНеприменима(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
var in []store.IncomingPoint
|
||||
for hh := range catalogAgreeingHours {
|
||||
hour := pointsAt(hh, 0)
|
||||
in = append(in,
|
||||
pointAt("active_energy", "hour", "kJ", hour, hour, `{"qty":30}`),
|
||||
pointAt("active_energy", "minute", "kJ", hour, hour, `{"qty":10}`),
|
||||
pointAt("active_energy", "minute", "kJ", hour.Add(time.Minute), hour.Add(time.Minute), `{"qty":20}`),
|
||||
pointAt("active_energy", "raw", "kJ", hour.Add(7*time.Second), hour.Add(7*time.Second), `{"qty":1}`),
|
||||
)
|
||||
}
|
||||
mergePoints(t, st, in...)
|
||||
|
||||
var got struct {
|
||||
Aggregation struct {
|
||||
Style string `json:"style"`
|
||||
Applicable bool `json:"applicable"`
|
||||
} `json:"aggregation"`
|
||||
}
|
||||
decodeBody(t, getPoints(t, h, "active_energy", dayWindow+"&layer=raw", ""), &got)
|
||||
|
||||
if got.Aggregation.Style != "cumulative" {
|
||||
t.Fatalf("род %q, ожидался cumulative", got.Aggregation.Style)
|
||||
}
|
||||
if got.Aggregation.Applicable {
|
||||
t.Error("накопительная метрика на нижнем слое объявлена применимой — потребитель просуммирует интерполяцию")
|
||||
}
|
||||
}
|
||||
|
||||
// Содержимое точки уезжает ДОСЛОВНО, включая символы, которые `encoding/json`
|
||||
// по умолчанию превращает в escape-последовательности.
|
||||
//
|
||||
// Случай заведён отдельно и намеренно: фикстуры `testdata` символов `&<>` не
|
||||
// содержат вовсе, то есть утверждение о дословности на них зелено и будучи
|
||||
// сломанным. Имя источника приходит с телефона пользовательской строкой.
|
||||
func TestТочкиСодержимоеНеЭкранируется(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
raw := `{"qty":1,"source":"iPhone <A&B>"}`
|
||||
mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), raw))
|
||||
|
||||
body := getPoints(t, h, "m", dayWindow, "").Body.String()
|
||||
if !strings.Contains(body, raw) {
|
||||
t.Errorf("содержимое точки переписано сериализатором:\n %s", body)
|
||||
}
|
||||
for _, escaped := range []string{`\u0026`, `\u003c`, `\u003e`} {
|
||||
if strings.Contains(body, escaped) {
|
||||
t.Errorf("сериализатор заэкранировал содержимое точки (%s) — обещание дословности нарушено", escaped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Интервальная точка отдаёт КОНЕЦ координаты: под одной меткой лежит до трёх
|
||||
// записей сна, и конверт с одним `ts` предложил бы клиенту различать их,
|
||||
// разбирая дословное содержимое.
|
||||
func TestТочкиИнтервалОтдаётКонецКоординаты(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
start, end := pointsAt(1, 0), pointsAt(3, 30)
|
||||
mergePoints(t, st,
|
||||
pointAt("sleep_analysis", "raw", "hr", start, end, `{"value":"В кровати"}`),
|
||||
pointAt("sleep_analysis", "raw", "hr", start, pointsAt(2, 0), `{"value":"Глубокий"}`),
|
||||
)
|
||||
|
||||
var got struct {
|
||||
Points []struct {
|
||||
TS string `json:"ts"`
|
||||
TSEnd string `json:"ts_end"`
|
||||
} `json:"points"`
|
||||
}
|
||||
decodeBody(t, getPoints(t, h, "sleep_analysis", dayWindow, ""), &got)
|
||||
|
||||
if len(got.Points) != 2 {
|
||||
t.Fatalf("точек %d, ожидалось 2: одна метка, разные интервалы", len(got.Points))
|
||||
}
|
||||
if got.Points[0].TS != got.Points[1].TS {
|
||||
t.Fatalf("метки разошлись: %s и %s", got.Points[0].TS, got.Points[1].TS)
|
||||
}
|
||||
if got.Points[0].TSEnd == got.Points[1].TSEnd {
|
||||
t.Errorf("концы координат совпали (%s) — записи неразличимы", got.Points[0].TSEnd)
|
||||
}
|
||||
// Порядок детерминирован: при равном начале раньше идёт более короткий.
|
||||
if got.Points[0].TSEnd != "2026-06-01T02:00:00Z" {
|
||||
t.Errorf("порядок точек не по (ts, ts_end): первый конец %s", got.Points[0].TSEnd)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой период — успех, а не отсутствие ресурса. `layer` при этом `null`
|
||||
// ТОЛЬКО когда слой выбирала система: клиент, спросивший разрез поимённо,
|
||||
// обязан отличать «этого разреза за период нет» от «параметр проигнорирован».
|
||||
func TestТочкиПустойПериодОтличаетВыбранныйСлойОтЗапрошенного(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`))
|
||||
|
||||
empty := "from=2026-07-01T00:00:00Z&to=2026-07-02T00:00:00Z"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
want string
|
||||
}{
|
||||
{"слой выбирала система", empty, "null"},
|
||||
{"слой задан явно", empty + "&layer=minute", `"minute"`},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var got struct {
|
||||
Layer json.RawMessage `json:"layer"`
|
||||
Points []json.RawMessage `json:"points"`
|
||||
}
|
||||
rec := getPoints(t, h, "m", c.query, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, ожидался 200: «данных нет» — не «ресурса нет»", rec.Code)
|
||||
}
|
||||
decodeBody(t, rec, &got)
|
||||
|
||||
if string(got.Layer) != c.want {
|
||||
t.Errorf("слой %s, ожидался %s", got.Layer, c.want)
|
||||
}
|
||||
if got.Points == nil {
|
||||
t.Error("точки уехали как null — клиент прочитает «поля нет» вместо «точек нет»")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Имени метрики в витрине нет вовсе — это тоже `200` с пустым рядом: список
|
||||
// имён маршруту точек не принадлежит, их отдаёт каталог.
|
||||
func TestТочкиНезнакомойМетрикиОтвечают200(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
rec := getPoints(t, h, "нет такой метрики", dayWindow, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, ожидался 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя метрики достаётся из пути ДЕКОДИРОВАННЫМ. Без этого метрика с пробелом
|
||||
// или слэшем в имени была бы недостижима, а имена приходят из тела доставки
|
||||
// дословно и ничем не ограничены.
|
||||
func TestТочкиИмяМетрикиДекодируетсяИзПути(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
for _, metric := range []string{"с пробелом", "со/слэшем", "с%знаком"} {
|
||||
t.Run(metric, func(t *testing.T) {
|
||||
mergePoints(t, st, pointAt(metric, "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`))
|
||||
|
||||
var got struct {
|
||||
Metric string `json:"metric"`
|
||||
Points []json.RawMessage `json:"points"`
|
||||
}
|
||||
decodeBody(t, getPoints(t, h, metric, dayWindow, ""), &got)
|
||||
|
||||
if got.Metric != metric {
|
||||
t.Errorf("имя метрики %q, ожидалось %q", got.Metric, metric)
|
||||
}
|
||||
if len(got.Points) != 1 {
|
||||
t.Errorf("точек %d, ожидалась 1 — метрика недостижима по своему имени", len(got.Points))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Маршрут точек НЕ перехватывает каталог: у chi литеральный `/metrics` и
|
||||
// шаблон `/metrics/{metric}` — разные узлы, но верить в это нельзя.
|
||||
func TestТочкиНеПерехватываютКаталог(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != `{"metrics":[]}` {
|
||||
t.Errorf("каталог перехвачен маршрутом точек: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Разбор параметров: каждый отказ — `400`, до чтения витрины, и без единого
|
||||
// значения из запроса в теле ответа.
|
||||
func TestТочкиОтвергаютНевозможныйЗапрос(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
cases := map[string]string{
|
||||
"нет from": "to=2026-06-02T00:00:00Z",
|
||||
"нет to": "from=2026-06-01T00:00:00Z",
|
||||
"голая дата": "from=2026-06-01&to=2026-06-02",
|
||||
"без зоны": "from=2026-06-01T00:00:00&to=2026-06-02T00:00:00Z",
|
||||
"мусор": "from=вчера&to=сегодня",
|
||||
"вывернутый период": "from=2026-06-02T00:00:00Z&to=2026-06-01T00:00:00Z",
|
||||
"пустой период": "from=2026-06-01T00:00:00Z&to=2026-06-01T00:00:00Z",
|
||||
"незнакомый слой": dayWindow + "&layer=weekly",
|
||||
"свёртка не поддержана": dayWindow + "&bucket=day",
|
||||
}
|
||||
|
||||
for name, query := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := getPoints(t, h, "секретное_имя_метрики", query, "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("статус %d, ожидался 400 (тело %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Значения из запроса в тело отказа не уезжают: там имя метрики и
|
||||
// границы периода, а тело отказа читает кто угодно.
|
||||
for _, leak := range []string{"2026-06-01", "2026-06-02", "weekly", "вчера", "секретное_имя_метрики"} {
|
||||
if strings.Contains(rec.Body.String(), leak) {
|
||||
t.Errorf("в теле отказа значение из запроса (%q): %s", leak, rec.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Незнакомые параметры игнорируются, как принято в HTTP: клиент, приславший
|
||||
// лишнее, получает данные, а не отказ.
|
||||
func TestТочкиИгнорируютНезнакомыйПараметр(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
rec := getPoints(t, h, "m", dayWindow+"&limit=10", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, ожидался 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestТочкиТребуютТокенЧтения(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, []string{"write-token"}, []string{"read-token"})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
auth string
|
||||
want int
|
||||
}{
|
||||
{"без заголовка", "", http.StatusUnauthorized},
|
||||
{"токен приёма", "Bearer write-token", http.StatusUnauthorized},
|
||||
{"токен чтения", "Bearer read-token", http.StatusOK},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := getPoints(t, h, "m", dayWindow, c.auth).Code; got != c.want {
|
||||
t.Errorf("статус %d, ожидался %d", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Метка ответа различает каждый параметр по очереди при ОДНОМ состоянии
|
||||
// витрины: метка, их не различающая, однажды подтвердит неизменность чужого
|
||||
// набора данных. Заодно — метка точек отличается от метки каталога.
|
||||
func TestМеткаТочекРазличаетЗапросы(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
mergePoints(t, st,
|
||||
pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`),
|
||||
pointAt("other", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`),
|
||||
)
|
||||
|
||||
base := getPoints(t, h, "m", dayWindow, "").Header().Get("ETag")
|
||||
if base == "" {
|
||||
t.Fatal("ответ точек ушёл без метки — условному запросу не на чем стоять")
|
||||
}
|
||||
if base == getCatalog(t, h, "").Header().Get("ETag") {
|
||||
t.Error("метка точек совпала с меткой каталога")
|
||||
}
|
||||
|
||||
others := map[string]func() string{
|
||||
"метрика": func() string { return getPoints(t, h, "other", dayWindow, "").Header().Get("ETag") },
|
||||
"начало": func() string {
|
||||
return getPoints(t, h, "m", "from=2026-06-01T01:00:00Z&to=2026-06-02T00:00:00Z", "").Header().Get("ETag")
|
||||
},
|
||||
"конец": func() string {
|
||||
return getPoints(t, h, "m", "from=2026-06-01T00:00:00Z&to=2026-06-03T00:00:00Z", "").Header().Get("ETag")
|
||||
},
|
||||
"слой": func() string { return getPoints(t, h, "m", dayWindow+"&layer=raw", "").Header().Get("ETag") },
|
||||
}
|
||||
for name, get := range others {
|
||||
if got := get(); got == base {
|
||||
t.Errorf("метка не различает %s: %s", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Эквивалентная запись границ даёт ту же метку: иначе условный запрос не
|
||||
// сработал бы у клиента, пишущего смещение зоны иначе, чем сервер.
|
||||
same := getPoints(t, h, "m", "from=2026-06-01T03:00:00%2B03:00&to=2026-06-02T03:00:00%2B03:00", "").Header().Get("ETag")
|
||||
if same != base {
|
||||
t.Errorf("эквивалентная запись дала другую метку:\n %s\n %s", same, base)
|
||||
}
|
||||
}
|
||||
|
||||
// Ответ чтения непригоден для разделяемого кеша: при выключенной проверке
|
||||
// токенов в запросе нет и `Authorization`, и выгрузку истории здоровья вправе
|
||||
// подержать у себя любой прокси на пути.
|
||||
func TestТочкиПомеченыЧастнымКешем(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
if got := getPoints(t, h, "m", dayWindow, "").Header().Get("Cache-Control"); got != "private, no-cache" {
|
||||
t.Errorf("Cache-Control %q, ожидался private, no-cache", got)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||||
t.Helper()
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||
t.Fatalf("разбор ответа: %v (тело %s)", err, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Отказ хранилища переводится в `500` с человекочитаемым сообщением: наружу не
|
||||
// уходит ни текст ошибки (в нём имена колонок), ни пустой ряд, который клиент
|
||||
// прочитал бы как «данных нет».
|
||||
func TestТочкиОтказХранилищаДаёт500(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`))
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("закрытие: %v", err)
|
||||
}
|
||||
|
||||
rec := getPoints(t, h, "m", dayWindow, "")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("статус %d, ожидался 500 (тело %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "bucket") || strings.Contains(rec.Body.String(), "select") {
|
||||
t.Errorf("в теле отказа внутренности хранилища: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Имя метрики, содержащее процентную последовательность, обязано доехать до
|
||||
// витрины НЕИЗМЕНЁННЫМ.
|
||||
//
|
||||
// Путь построен враждебным проходом и стоил ответа данными ЧУЖОЙ метрики:
|
||||
// клиент кодирует `a%41b` в `a%2541b`, `net/url` декодирует это обратно в
|
||||
// `a%41b` и оставляет `RawPath` пустым, а второе декодирование давало `aAb` —
|
||||
// имя соседней метрики, лежащей рядом в витрине.
|
||||
func TestТочкиИмяМетрикиНеДекодируетсяДважды(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
// Обе метрики лежат рядом: подмена наблюдаема числом точек.
|
||||
mergePoints(t, st,
|
||||
pointAt("a%41b", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`),
|
||||
pointAt("aAb", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":2}`),
|
||||
pointAt("aAb", "raw", "count", pointsAt(9, 1), pointsAt(9, 1), `{"qty":3}`),
|
||||
)
|
||||
|
||||
for _, metric := range []string{"a%41b", "aAb", "100%", "a%zzb", "a+b", "шаги", "со/слэшем", "с пробелом"} {
|
||||
t.Run(metric, func(t *testing.T) {
|
||||
var got struct {
|
||||
Metric string `json:"metric"`
|
||||
}
|
||||
decodeBody(t, getPoints(t, h, metric, dayWindow, ""), &got)
|
||||
if got.Metric != metric {
|
||||
t.Errorf("маршрут ответил о метрике %q, спрашивали %q", got.Metric, metric)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// И прямая проверка исхода: у `a%41b` одна точка, у `aAb` — две.
|
||||
var got struct {
|
||||
Points []json.RawMessage `json:"points"`
|
||||
}
|
||||
decodeBody(t, getPoints(t, h, "a%41b", dayWindow, ""), &got)
|
||||
if len(got.Points) != 1 {
|
||||
t.Errorf("точек %d, ожидалась 1 — ответ собран по чужой метрике", len(got.Points))
|
||||
}
|
||||
}
|
||||
|
||||
// Пустое значение `?layer=` — промах клиента, а не «слой не задан». Разбор по
|
||||
// непустоте молча включал бы автоматический выбор, и клиент, спросивший разрез
|
||||
// поимённо, не отличил бы свой промах от ответа по существу.
|
||||
func TestТочкиОтвергаютПустойСлой(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
if got := getPoints(t, h, "m", dayWindow+"&layer=", "").Code; got != http.StatusBadRequest {
|
||||
t.Errorf("статус %d, ожидался 400", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Границы, различающиеся ДОЛЯМИ СЕКУНДЫ, дают разные метки: отбор точек идёт по
|
||||
// полной метке, значит и ряды разные. Путь построен враждебным проходом — две
|
||||
// побайтово одинаковые метки на разных телах, то есть будущий `304` на чужом
|
||||
// наборе данных.
|
||||
func TestМеткаТочекРазличаетДолиСекунды(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(0, 0), pointsAt(0, 0), `{"qty":1}`))
|
||||
|
||||
whole := getPoints(t, h, "m", "from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z", "")
|
||||
fraction := getPoints(t, h, "m", "from=2026-06-01T00:00:00.500Z&to=2026-06-02T00:00:00Z", "")
|
||||
|
||||
if a, b := whole.Header().Get("ETag"), fraction.Header().Get("ETag"); a == b {
|
||||
t.Errorf("метка не различает доли секунды: %s", a)
|
||||
}
|
||||
if whole.Body.String() == fraction.Body.String() {
|
||||
t.Error("тела совпали — вход подобран неверно, утверждение о метках ничего не доказывает")
|
||||
}
|
||||
}
|
||||
|
||||
// Метка ОГРАНИЧЕНА по длине и не выносит наружу имя метрики.
|
||||
//
|
||||
// Имя приходит из чужого тела дословно и ничем не ограничено: без предела
|
||||
// заголовок `ETag` разрастался вместе с ним (прогнано: имя в 3000 байт давало
|
||||
// заголовок в 3099). Кавычка в имени по RFC 9110 кончает метку, и собственный
|
||||
// `scanETag` проекта обрезал бы её ровно там — условный запрос по такой метрике
|
||||
// не сработал бы никогда.
|
||||
func TestМеткаТочекОграниченаИНеНесётИмя(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
long := strings.Repeat("щ", 1000) + `quote"inside`
|
||||
tag := getPoints(t, h, long, dayWindow, "").Header().Get("ETag")
|
||||
|
||||
if tag == "" {
|
||||
t.Fatal("ответ ушёл без метки")
|
||||
}
|
||||
if len(tag) > 128 {
|
||||
t.Errorf("метка в %d байт — имя метрики уехало в заголовок целиком", len(tag))
|
||||
}
|
||||
if strings.Contains(tag, "щ") || strings.Contains(tag, `quote"`) {
|
||||
t.Errorf("имя метрики видно в метке: %s", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// brokenWriter — ResponseWriter, отказывающий на записи тела. Заголовки и код
|
||||
// он принимает: обрыв случается ПОСЛЕ того, как `200` уже отдан, — ровно так
|
||||
// это выглядит при сработавшем дедлайне записи или ушедшем клиенте.
|
||||
type brokenWriter struct {
|
||||
header http.Header
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *brokenWriter) Header() http.Header { return w.header }
|
||||
func (w *brokenWriter) WriteHeader(s int) { w.status = s }
|
||||
func (w *brokenWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("соединение оборвано")
|
||||
}
|
||||
|
||||
// Обрыв записи тела оставляет собственный чекпоинт.
|
||||
//
|
||||
// Код ответа отдан до сериализации, поэтому `accessLog` честно напишет `200` —
|
||||
// то есть единственный канал наблюдаемости сообщит успех о неотданном ответе.
|
||||
// Путь построен враждебным проходом на настоящем сокете: тело в 13 МиБ
|
||||
// оборвалось на 2.7 МиБ, клиент получил нечитаемый JSON, лог — `200`.
|
||||
func TestТочкиОбрывЗаписиТелаНаблюдаем(t *testing.T) {
|
||||
h, st, _, seen := newAPILogged(t, nil, nil)
|
||||
mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`))
|
||||
seen.reset()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics/m?"+dayWindow, nil)
|
||||
w := &brokenWriter{header: http.Header{}}
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.status != http.StatusOK {
|
||||
t.Fatalf("статус %d, ожидался 200: обрыв случается после кода ответа", w.status)
|
||||
}
|
||||
if !seen.has("points response truncated") {
|
||||
t.Error("обрыв записи прошёл молча — владелец увидит только успешный 200")
|
||||
}
|
||||
}
|
||||
|
||||
// Словарь слоёв ОДИН: `hae.Layers`. Три копии — порядок, перечень выборки и
|
||||
// текст отказа — разошлись бы молча, и симптомом был бы пустой ряд при
|
||||
// непустых данных.
|
||||
func TestСловарьСлоёвОдин(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
for _, l := range hae.Layers {
|
||||
if got := getPoints(t, h, "m", dayWindow+"&layer="+string(l), "").Code; got != http.StatusOK {
|
||||
t.Errorf("слой %q отвергнут статусом %d, хотя он в словаре", l, got)
|
||||
}
|
||||
}
|
||||
|
||||
rec := getPoints(t, h, "m", dayWindow+"&layer=weekly", "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("статус %d, ожидался 400", rec.Code)
|
||||
}
|
||||
for _, l := range hae.Layers {
|
||||
if !strings.Contains(rec.Body.String(), string(l)) {
|
||||
t.Errorf("текст отказа не называет слой %q: %s", l, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Граница, уезжающая за четырёхзначный год, отвергается, а не отдаёт пустой ряд.
|
||||
//
|
||||
// Объекты адресуются строкой RFC 3339, границы сравниваются лексикографически:
|
||||
// `9999-12-31T23:00:00-07:00` становится `10000-01-01T06:00:00Z`, который как
|
||||
// строка меньше любой настоящей метки. Без отказа запрос молча вернул бы пустой
|
||||
// ряд при непустых данных — найдено триажем.
|
||||
func TestТочкиОтвергаютГраницуЗаЧетырёхзначнымГодом(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
mergePoints(t, st, pointAt("m", "raw", "count", pointsAt(9, 0), pointsAt(9, 0), `{"qty":1}`))
|
||||
|
||||
rec := getPoints(t, h, "m", "from=2020-01-01T00:00:00Z&to=9999-12-31T23:00:00-07:00", "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("статус %d, ожидался 400 (тело %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user