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

678 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}