httpapi: точки метрики за период отдаются одним запросом

- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
  объявляет слой, измеренный род, его применимость к отданному ряду и границу
  окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
  точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
  под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
  хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
  записи: дословность содержимого точки иначе не удерживается, а оборванное
  тело уходило под видом успешного `200`
This commit is contained in:
av
2026-08-04 18:46:45 +03:00
parent b819b77f62
commit 29ca8d415c
36 changed files with 4721 additions and 58 deletions
+4 -1
View File
@@ -159,5 +159,8 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
// ответ уходит без метки: это ровно поведение до появления условного
// запроса, то есть деградация в безопасную сторону.
setReadHeaders(w, etag(scopeMetrics, snap.Version))
writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
// Обрыв записи каталога глушится: ответ здесь — три десятка строк, и
// оборваться на нём нечему. У маршрута точек ряд ничем не ограничен, и там
// отказ записи наблюдается отдельным чекпоинтом.
_ = writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
}
+30 -3
View File
@@ -16,12 +16,14 @@ import (
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/points"
)
// Options — зависимости и настройки транспорта.
type Options struct {
Ingest *ingest.Service
Catalog *catalog.Service
Points *points.Service
Log *slog.Logger
WriteTokens []string
ReadTokens []string
@@ -35,6 +37,7 @@ type Options struct {
type api struct {
ingest *ingest.Service
catalog *catalog.Service
points *points.Service
log *slog.Logger
writeTokens []string
readTokens []string
@@ -47,6 +50,7 @@ func New(o Options) http.Handler {
a := &api{
ingest: o.Ingest,
catalog: o.Catalog,
points: o.Points,
log: o.Log,
writeTokens: o.WriteTokens,
readTokens: o.ReadTokens,
@@ -62,6 +66,10 @@ func New(o Options) http.Handler {
r.Route("/api/v1", func(r chi.Router) {
r.With(requireToken(a.writeTokens)).Post("/ingest", a.handleIngest)
r.With(requireToken(a.readTokens)).Get("/metrics", a.handleMetrics)
// Маршрут точек стоит РЯДОМ с каталогом, а не поверх него: у chi
// литеральный `/metrics` и шаблон `/metrics/{metric}` — разные узлы, и
// каталог остаётся достижим. Утверждается это тестом, а не верой.
r.With(requireToken(a.readTokens)).Get("/metrics/{metric}", a.handlePoints)
})
return r
}
@@ -152,10 +160,27 @@ func routePattern(r *http.Request) string {
return r.URL.Path
}
func writeJSON(w http.ResponseWriter, status int, v any) {
// writeJSON — единственный сериализатор тел ответа.
//
// Экранирование HTML ВЫКЛЮЧЕНО, и это не косметика. `encoding/json` по
// умолчанию превращает `&`, `<` и `>` в `\u0026`, `\u003c`, `\u003e`; на
// маршруте, отдающем дословно сохранённое содержимое точки, это прямо ломает
// обещание дословности — имя источника приходит с телефона пользовательской
// строкой и законно содержит `&`. Хранилище этот же капкан уже проходило и
// обезвредило тем же способом (`store.encodePayload`).
//
// Правило общее для всех читающих маршрутов намеренно: механизм один, и
// решать его заново на каждом маршруте значило бы завести второй способ.
// Отказ записи ВОЗВРАЩАЕТСЯ, а не глушится: код ответа отдан до сериализации,
// поэтому оборванное на середине тело снаружи неотличимо от успеха, а
// `accessLog` честно напишет `200`. Кто из вызывающих обязан об этом сказать —
// решает он сам; глушить молча нельзя ни одному.
func writeJSON(w http.ResponseWriter, status int, v any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(v)
}
// errorWire — форма провода тела отказа, общая для всех маршрутов.
@@ -173,5 +198,7 @@ type errorWire struct {
// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте
// имена колонок и форма запроса.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorWire{Error: msg})
// Тело отказа — десятки байт: оборваться на нём нечему, и сообщать о
// таком обрыве было бы шумом.
_ = writeJSON(w, status, errorWire{Error: msg})
}
+2
View File
@@ -18,6 +18,7 @@ import (
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/httpapi"
"git.vakhrushev.me/av/healthlog/internal/ingest"
"git.vakhrushev.me/av/healthlog/internal/points"
"git.vakhrushev.me/av/healthlog/internal/store"
)
@@ -324,6 +325,7 @@ func newAPILogged(t *testing.T, writeTokens, readTokens []string) (http.Handler,
h := httpapi.New(httpapi.Options{
Ingest: ingest.New(arch, st, nil, log),
Catalog: cat,
Points: points.New(st, log),
Log: log,
WriteTokens: writeTokens,
ReadTokens: readTokens,
+3 -1
View File
@@ -42,7 +42,9 @@ func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, ingestResponse{
// Ответ приёма — три поля; обрыв записи на нём означает ушедшего клиента, а
// доставка уже сохранена, и это единственное, что здесь обещано.
_ = writeJSON(w, http.StatusOK, ingestResponse{
DeliveryID: res.DeliveryID,
Bytes: res.Bytes,
SHA256: res.SHA256,
+316
View File
@@ -0,0 +1,316 @@
package httpapi
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"git.vakhrushev.me/av/healthlog/internal/catalog"
"git.vakhrushev.me/av/healthlog/internal/hae"
"git.vakhrushev.me/av/healthlog/internal/points"
)
// ФОРМА ПРОВОДА ТОЧЕК. Объявлена здесь и только здесь — доменные типы
// `internal/points` `json`-тегов не несут и до сериализации не доезжают.
// Образец и его цена — `internal/httpapi/catalog.go`; выбирать заново не нужно.
// pointsResponse — оболочка ответа точек.
//
// Поля присутствуют ВСЕГДА, даже когда сообщить нечего: клиент не должен
// выводить исход наличием или отсутствием поля. Отсюда указатели у `layer` и
// `bucket` — `null` означает «слоя нет» и «свёртки не было», а не пустую
// строку, которая была бы законным именем слоя.
type pointsResponse struct {
Metric string `json:"metric"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Layer *string `json:"layer"`
// Bucket — сетка свёртки. Всегда `null` в этой версии маршрута: свёртку
// делает соседняя задача. Поле объявлено сразу, потому что менять форму
// конверта после того, как его скопировали четыре маршрута, — не развилка,
// а археология.
Bucket *string `json:"bucket"`
Aggregation seriesAggregation `json:"aggregation"`
Points []pointWire `json:"points"`
}
// seriesAggregation — род свёртки вместе с его применимостью и свежестью.
//
// `applicable` не украшение: род — свойство МЕТРИКИ, слой — свойство ОТДАННОГО
// РЯДА, и сочетание `{"layer": "raw", "style": "cumulative"}` законно, штатно и
// прямо приглашает потребителя сложить интерполяцию самому. Система при этом
// ничего не складывает, а решение у потребителя уже принято по завышенному
// втрое числу.
//
// `last_hour` — ярлык самого свежего часа окна измерения. Окно считается в
// ОБЩИХ часах, а не в часах календаря: выключенная минутная автоматизация HAE
// останавливает их пополнение, окно замирает и продолжает объявлять род.
// Единственный след этого — вот это поле.
type seriesAggregation struct {
Style string `json:"style"`
Applicable bool `json:"applicable"`
LastHour *time.Time `json:"last_hour"`
}
// pointWire — точка ряда на проводе.
//
// `values` уезжает СЫРЫМ JSON: точки хранятся дословно, и переписывать их в тип
// провода значило бы нарушить инвариант. Конверт вокруг значения при этом
// объявлен и нормализован, как того требует «форма Apple не транслируется».
//
// `ts_end` есть потому, что идентичность точки — интервал, а не метка: под
// одной меткой лежит до трёх записей сна. У точки-измерения он равен `ts`.
type pointWire struct {
TS time.Time `json:"ts"`
TSEnd time.Time `json:"ts_end"`
TZOffset int `json:"tz_offset"`
Units string `json:"units"`
Values json.RawMessage `json:"values"`
}
// pointsWire переводит ряд домена в форму провода.
//
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`.
func pointsWire(s points.Series) pointsResponse {
out := pointsResponse{
Metric: s.Metric,
From: s.From,
To: s.To,
Aggregation: seriesAggregation{
Style: s.Style.String(),
Applicable: s.Applicable,
// Указатель переносится КАК УКАЗАТЕЛЬ: разыменование дало бы
// `0001-01-01T00:00:00Z` там, где окна не было, а правдоподобная
// дата в ответе неотличима от настоящей.
LastHour: s.LastHour,
},
// Непустой срез, а не nil: nil сериализуется в `null`, и клиент
// прочитал бы «поля нет» вместо «точек нет».
Points: make([]pointWire, 0, len(s.Points)),
}
if s.Layer != "" {
layer := s.Layer
out.Layer = &layer
}
for _, p := range s.Points {
out.Points = append(out.Points, pointWire{
TS: p.TS,
TSEnd: p.End,
TZOffset: p.OffsetSeconds,
Units: p.Units,
Values: p.Raw,
})
}
return out
}
// pointsScope — область действия метки ответа: ХЕШ канонизированной формы
// запроса.
//
// Живёт В ТРАНСПОРТЕ, и это решение, а не случайность: у каталога область тоже
// транспортная (`scopeMetrics`), а помощник `etag` прямо просит маршрут положить
// сюда канонизированную форму запроса. Считай её домен — у одного понятия
// оказалось бы два дома, и три следующих маршрута выбирали бы между ними
// монетой. Заодно домен перестал бы носить транспортный артефакт: префикс и hex
// — это форма метки HTTP, а форму провода объявляет транспорт (ADR).
//
// Метка действительна в пределах ОДНОГО набора данных, а ответ этого маршрута
// есть функция параметров. Метка без них однажды подтвердила бы неизменность
// чужого набора — молча и без следов.
//
// Канонизация: границы приводятся к UTC в RFC 3339 с ПОЛНОЙ точностью. Полной,
// а не посекундной, потому что отбор точек идёт по полной метке: два запроса,
// различающиеся долей секунды, дают разные ряды, и одна область на них
// означала бы `304` на чужом наборе данных. Зона при этом канонизируется —
// одно и то же время, записанное разными смещениями, даёт одну область.
//
// Слой берётся ЗАПРОШЕННЫЙ, а не выбранный: областью является запрос, а смену
// выбранного слоя от новых данных ловит версия витрины.
//
// ХЕШ, а не сама форма, и обе причины из `docs/security.md`. Имя метрики
// приходит из чужого тела дословно и ничем не ограничено — в заголовке ответа
// оно дало бы `ETag` в тысячи байт, притом что тот же вход в лог уезжает
// обрезанным. И оно законно содержит кавычку, которая по RFC 9110 кончает
// метку: собственный `scanETag` проекта обрезал бы её ровно там, и условный
// запрос по такой метрике не сработал бы никогда, а симптом увёл бы отладку в
// помощника. Длина впереди остаётся внутри хешируемой строки: без неё имя с
// разделителем склеилось бы с соседним полем.
func pointsScope(req points.Request) string {
canonical := fmt.Sprintf("%d:%s|%s|%s|%s",
len(req.Metric), req.Metric,
req.From.UTC().Format(time.RFC3339Nano),
req.To.UTC().Format(time.RFC3339Nano),
req.Layer)
sum := sha256.Sum256([]byte(canonical))
return "points." + hex.EncodeToString(sum[:scopeBytes])
}
// scopeBytes — сколько байтов хеша попадает в область.
//
// Шестнадцать: 128 бит, то есть столкновение неотличимо от невозможного, а
// заголовок остаётся короче метки каталога. Хеш здесь не криптографический
// секрет — он ограничитель длины и экранирование разом.
const scopeBytes = 16
// handlePoints отдаёт точки метрики за период.
func (a *api) handlePoints(w http.ResponseWriter, r *http.Request) {
req, msg := parsePointsRequest(r)
if msg != "" {
// Отказ до чтения витрины: невозможный запрос не должен стоить снимка.
// В сообщении нет ни одного значения из запроса — оно уедет клиенту, а
// в запросе имя метрики и границы периода.
writeError(w, http.StatusBadRequest, msg)
return
}
series, err := a.points.Series(r.Context(), req)
if err != nil {
// Исход операции логирует доменный слой, транспорт только переводит его
// в ответ. Наружу уходит человекочитаемое сообщение, а не текст ошибки:
// в нём имена колонок и форма запроса.
writeError(w, http.StatusInternalServerError, "ряд точек не собрался")
return
}
// Область действия метки — канонизированная форма запроса: ответ этого
// маршрута есть функция параметров, и метка без них однажды подтвердила бы
// неизменность чужого набора данных. Горизонт измерения в метке уже есть —
// его кладёт домен вместе с версией витрины.
setReadHeaders(w, etag(pointsScope(req), series.Version))
if err := writeJSON(w, http.StatusOK, pointsWire(series)); err != nil {
// Тело оборвалось на середине: дедлайн записи, ушедший клиент, полный
// буфер посредника. Код ответа уже отдан, и `accessLog` напишет `200` —
// то есть единственный канал наблюдаемости сообщит успех о неотданном
// ответе. Ряд ничем не ограничен по размеру (предел — соседняя задача),
// поэтому случай не гипотетический: неделя нижнего слоя это 280 МиБ.
//
// WARN, а не DEBUG, и это исправление находки эксплуатационного прохода:
// боевой уровень логирования — `info` (`config.docker.toml`), то есть
// запись уровня `DEBUG` не прошла бы фильтр НИКОГДА, и единственный
// признак недоставленного тела не существовал бы в проде вовсе.
// Обесценивания уровня здесь нет: событие не периодическое — оно
// означает, что потребитель получил битый JSON.
//
// Значений точек и границ запроса в записи нет; имя метрики обрезано.
a.log.WarnContext(r.Context(), "points response truncated",
"capability", "query", "metric", catalog.ClipMetric(req.Metric), "error", err)
}
}
// parsePointsRequest разбирает параметры маршрута точек.
//
// Второй возврат — человекочитаемая причина отказа; пустая строка означает, что
// запрос принят. Строка, а не ошибка: она целиком уезжает клиенту, поэтому
// обязана быть составлена здесь и не содержать ни одного значения из запроса.
func parsePointsRequest(r *http.Request) (points.Request, string) {
q := r.URL.Query()
// Свёртка по сетке ещё не поддержана, и молчать об этом нельзя: клиент,
// попросивший суточную сетку и получивший полный минутный ряд, заметил бы
// подмену, только пересчитав точки. Это зеркало того самого промаха, ради
// которого «сетка задана явно» вообще различается.
if q.Has("bucket") {
return points.Request{}, "свёртка по сетке ещё не поддержана: параметр bucket не принимается"
}
from, msg := parseBound(q, "from")
if msg != "" {
return points.Request{}, msg
}
to, msg := parseBound(q, "to")
if msg != "" {
return points.Request{}, msg
}
if !from.Before(to) {
return points.Request{}, "период пуст: from должен быть строго раньше to"
}
// `Has`, а не `Get() != ""`, и симметрично `bucket`. Пустое значение
// (`?layer=`) — самый частый способ промахнуться: шаблон клиента с
// невыставленной переменной. Разбор по непустоте молча включил бы
// автоматический выбор, и клиент, спросивший разрез поимённо, не отличил бы
// свой промах от ответа по существу.
layer := q.Get("layer")
if q.Has("layer") && !hae.Known(hae.Layer(layer)) {
return points.Request{}, "неизвестный слой: допустимы " + knownLayers
}
return points.Request{Metric: metricFromPath(r), From: from, To: to, Layer: layer}, ""
}
// parseBound разбирает границу периода.
//
// Только RFC 3339 и только с явным смещением зоны. Голая дата отвергается не из
// строгости: у неё нет зоны, а вопрос «в какой зоне считать сутки» в проекте
// открыт отдельной задачей. Принять её значило бы выбрать зону за клиента молча.
func parseBound(q url.Values, name string) (time.Time, string) {
raw := q.Get(name)
if raw == "" {
return time.Time{}, "параметр " + name + " обязателен"
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, "параметр " + name +
": ожидается метка времени RFC 3339 с явным смещением зоны, например 2026-01-01T00:00:00Z"
}
// Год после приведения к UTC обязан остаться четырёхзначным, и это не
// придирка к календарю. Хранилище адресует объекты строкой RFC 3339, а
// сравнение границ идёт лексикографически: `9999-12-31T23:00:00-07:00`
// превращается в `10000-01-01T06:00:00Z`, который меньше любой настоящей
// метки как строка, — и запрос молча отдал бы пустой ряд при непустых
// данных. Отказ здесь честнее пустоты: тот же разряд ловится и на входе
// приёма, но там он унаследован и лечится не тут.
if y := t.UTC().Year(); y < 1 || y > 9999 {
return time.Time{}, "параметр " + name + ": год после приведения к UTC вне диапазона 1–9999"
}
return t.UTC(), ""
}
// knownLayers — перечень допустимых слоёв ДЛЯ СООБЩЕНИЯ КЛИЕНТУ, собранный из
// того же словаря, что и проверка. Текст отказа уезжает наружу и потому обязан
// перечислять ровно то, что принимается: разъехавшись, он врал бы клиенту.
var knownLayers = func() string {
out := make([]string, 0, len(hae.Layers))
for _, l := range hae.Layers {
out = append(out, string(l))
}
return strings.Join(out, ", ")
}()
// metricFromPath достаёт имя метрики из пути.
//
// Решение принимается по `RawPath`, а НЕ по успеху декодирования, и это ровно
// то место, где легко ошибиться. `chi` сопоставляет по сырому пути только когда
// тот непуст, то есть когда `net/url` увидел в пути экранирование; тогда
// `{metric}` приезжает закодированным и его надо декодировать. Когда `RawPath`
// пуст, `chi` отдаёт уже декодированное имя — и второе декодирование испортило
// бы его молча.
//
// Цена ошибки построена и прогнана враждебным проходом: метрика с именем
// `a%41b` (имена приходят из тела доставки дословно) кодируется клиентом в
// `a%2541b`, `net/url` декодирует это в `a%41b` и оставляет `RawPath` пустым —
// а второе декодирование давало `aAb`, то есть маршрут отвечал `200` и данными
// ДРУГОЙ метрики.
//
// Отказ декодирования не является отказом маршрута: берём имя как есть — оно
// всё равно не совпадёт ни с одной метрикой витрины, и клиент получит честный
// пустой ряд, а не `400` на своё же имя.
func metricFromPath(r *http.Request) string {
raw := chi.URLParam(r, "metric")
if r.URL.RawPath == "" {
return raw
}
if decoded, err := url.PathUnescape(raw); err == nil {
return decoded
}
return raw
}
+677
View File
@@ -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())
}
}
+1
View File
@@ -101,6 +101,7 @@ func foreignTypes(t reflect.Type) []string {
func TestФормаПроводаДоменаНеСодержит(t *testing.T) {
cases := map[string]any{
"каталог": catalogResponse{},
"точки": pointsResponse{},
"тело отказа": errorWire{},
"учёт приёма": ingestResponse{},
}