- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
244 lines
11 KiB
Go
244 lines
11 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.vakhrushev.me/av/healthlog/internal/store"
|
||
)
|
||
|
||
func getCatalog(t *testing.T, h http.Handler, auth string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil)
|
||
if auth != "" {
|
||
req.Header.Set("Authorization", auth)
|
||
}
|
||
rec := httptest.NewRecorder()
|
||
h.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
|
||
// Пустая витрина отдаёт `[]`, а не `null`. Сравниваются БАЙТЫ: nil-срез
|
||
// сериализуется в `null`, и тест, сличающий разобранные структуры, этого не
|
||
// увидел бы — а клиент увидел бы сразу.
|
||
func TestКаталогПустойВитриныОтдаётПустойСписок(t *testing.T) {
|
||
h, _, _ := newAPITokens(t, nil, nil)
|
||
|
||
rec := getCatalog(t, h, "")
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("статус %d, тело %s", rec.Code, rec.Body.String())
|
||
}
|
||
if got := strings.TrimSpace(rec.Body.String()); got != `{"metrics":[]}` {
|
||
t.Errorf("тело %q, ждали {\"metrics\":[]}", got)
|
||
}
|
||
}
|
||
|
||
// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
|
||
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
|
||
//
|
||
// Сравниваются БАЙТЫ ЦЕЛОГО ТЕЛА, а не подстроки. Это ветка `*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)
|
||
}
|
||
}
|