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) } }