package httpapi_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/store" ) func conditionalGet(t *testing.T, h http.Handler, auth, cond string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/metrics", nil) if auth != "" { req.Header.Set("Authorization", auth) } if cond != "" { req.Header.Set("If-None-Match", cond) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func point(t *testing.T, st *store.Store, metric string, at time.Time) { t.Helper() _, err := st.Merge(context.Background(), store.Incoming{Points: []store.IncomingPoint{{ Metric: metric, Layer: "minute", Units: "count", Point: store.Point{Start: at, End: at, Raw: json.RawMessage(`{"qty":1}`)}, }}}, store.DeliveryRef{ID: "d"}) if err != nil { t.Fatalf("слияние: %v", err) } } // Обещание клиенту целиком: на неизменившейся витрине метка та же и байты те // же, а повтор с этой меткой не собирает снимка вовсе. func TestКаталогПовторНаНеизменившейсяВитрине(t *testing.T) { h, st, _ := newAPITokens(t, nil, nil) point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)) first := getCatalog(t, h, "") tag := first.Header().Get("ETag") if tag == "" { t.Fatal("ответ ушёл без метки") } if got := first.Header().Get("Cache-Control"); got != "private, no-cache" { t.Errorf("Cache-Control %q — выгрузку здоровья вправе сохранить любой посредник", got) } second := getCatalog(t, h, "") if got := second.Header().Get("ETag"); got != tag { t.Errorf("метка сдвинулась на неизменившейся витрине: %q → %q", tag, got) } if first.Body.String() != second.Body.String() { t.Error("два ответа на неизменившейся витрине разошлись байтами") } cond := conditionalGet(t, h, "", tag) if cond.Code != http.StatusNotModified { t.Fatalf("статус %d, ждали 304: %s", cond.Code, cond.Body.String()) } if cond.Body.Len() != 0 { t.Errorf("у 304 есть тело: %q", cond.Body.String()) } if got := cond.Header().Get("ETag"); got != tag { t.Errorf("метка на 304 — %q, ждали %q", got, tag) } if got := cond.Header().Get("Content-Type"); got != "" { t.Errorf("у 304 остались представленческие заголовки: Content-Type=%q", got) } } // Свёртка записала объект — метка обязана смениться, иначе клиент останется на // устаревшем ответе навсегда. func TestКаталогМенялсяПослеЗаписи(t *testing.T) { h, st, _ := newAPITokens(t, nil, nil) at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC) point(t, st, "step_count", at) tag := getCatalog(t, h, "").Header().Get("ETag") point(t, st, "heart_rate", at) rec := conditionalGet(t, h, "", tag) if rec.Code != http.StatusOK { t.Fatalf("статус %d, ждали 200 — витрина изменилась", rec.Code) } if got := rec.Header().Get("ETag"); got == tag { t.Error("метка не изменилась после записи в витрину") } } // Звёздочка совпадает с любой существующей меткой; мусор условия не выполняет // и отказом не является. func TestКаталогФормыУсловия(t *testing.T) { h, st, _ := newAPITokens(t, nil, nil) point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)) cases := []struct { name string cond string want int }{ {"звёздочка", "*", http.StatusNotModified}, {"мусор", "не-метка", http.StatusOK}, {"чужая метка", `W/"gen-нет"`, http.StatusOK}, } for _, c := range cases { if got := conditionalGet(t, h, "", c.cond).Code; got != c.want { t.Errorf("%s: статус %d, ждали %d", c.name, got, c.want) } } } // Токен проверяется РАНЬШЕ условия: `304` без токена подтверждал бы состояние // витрины тому, кому она не открыта. func TestУсловныйЗапросБезТокена(t *testing.T) { read := []string{"read-token"} h, st, _ := newAPITokens(t, nil, read) point(t, st, "step_count", time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)) tag := conditionalGet(t, h, "Bearer read-token", "").Header().Get("ETag") if tag == "" { t.Fatal("ответ ушёл без метки") } for _, cond := range []string{tag, "*"} { if got := conditionalGet(t, h, "", cond).Code; got != http.StatusUnauthorized { t.Errorf("условие %q без токена дало статус %d, ждали 401", cond, got) } } } // Смысл условного запроса — в том, что снимок не открывается вовсе. Оракул // внешний: каталог при сборке пишет владельцу предупреждение о данных из // будущего, и его отсутствие означает, что сборки не было. func TestУсловныйОтветНеСобираетКаталог(t *testing.T) { h, st, _, seen := newAPILogged(t, nil, nil) // Метка из будущего: при каждой сборке каталога она даёт `WARN`. point(t, st, "step_count", time.Now().UTC().Add(48*time.Hour)) tag := getCatalog(t, h, "").Header().Get("ETag") if !seen.has("future data") { t.Fatal("сборка каталога не дала ожидаемого предупреждения — оракул непригоден") } seen.reset() if got := conditionalGet(t, h, "", tag).Code; got != http.StatusNotModified { t.Fatalf("статус %d, ждали 304", got) } if seen.has("future data") { t.Error("условный ответ собрал каталог: снимок открыт зря") } }