Files
av a834d10415 httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
2026-08-04 16:18:05 +03:00

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