httpapi: форма провода читающих маршрутов объявлена транспортом

- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы
  metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте,
  тело отказа тоже получило объявленный тип — байты ответа не изменились
- заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до
  сериализации, плюс требование json-тега на полях транспортных структур и
  заведомо красные случаи к обоим правилам
- решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в
  гейте получил свой кеш — общий на машину красил прогон находками из чужого
  worktree
This commit is contained in:
av
2026-08-04 16:18:05 +03:00
parent bd832337df
commit a834d10415
19 changed files with 1721 additions and 53 deletions
+43 -11
View File
@@ -41,7 +41,12 @@ func TestКаталогПустойВитриныОтдаётПустойСпи
// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
func TestКаталогНеизмеренноеОкноОтдаётNull(t *testing.T) {
//
// Сравниваются БАЙТЫ ЦЕЛОГО ТЕЛА, а не подстроки. Это ветка `*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)
@@ -53,11 +58,13 @@ func TestКаталогНеизмеренноеОкноОтдаётNull(t *testi
t.Fatalf("слияние: %v", err)
}
body := getCatalog(t, h, "").Body.String()
for _, want := range []string{`"style":"unknown"`, `"first_hour":null`, `"last_hour":null`, `"hours":0`} {
if !strings.Contains(body, want) {
t.Errorf("в ответе нет %s: %s", want, body)
}
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)
}
}
@@ -123,9 +130,15 @@ func TestТокенЧтенияНеОседаетВУчётеДоставки(t
}
// Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками.
// Wire-форма каталога — это доменные структуры с json-тегами, и переименование
// поля меняет публичный контракт без единого касания транспорта; страж у него
// один — этот литерал.
//
// Литерал ниже — ДЕТЕКТОР ИЗМЕНЕНИЯ ФОРМЫ, а не сам контракт. Роли разведены
// намеренно: источником истины контракта станет рукописная OpenAPI-спека
// (решение владельца 2026-08-04, задача `openapi-spec`), а сверку спеки с
// маршрутами внесёт в гейт задача `openapi-gate-check`. Детектор при этом
// краснеет РАНЬШЕ гейта — в момент правки, — и в этом весь его смысл.
//
// Правишь литерал — правишь публичный контракт. Значит рядом обязана лежать
// правка спеки, а не только «чтобы позеленело».
func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
@@ -195,6 +208,11 @@ func TestКаталогПовторяетсяПобайтово(t *testing.T) {
// Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки
// наружу не уходит — в нём имена колонок и форма запроса.
//
// Тело отказа сравнивается БАЙТАМИ: у читающего маршрута это такая же часть
// публичного контракта, как успешный ответ, и клиент видит его чаще. Проверка
// «внутренностей не видно» осталась рядом, но она слабее: подстрок в ответе нет
// и у тела, которое переименовало ключ `error`.
func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) {
h, st, _ := newAPITokens(t, nil, nil)
if err := st.Close(); err != nil {
@@ -205,7 +223,21 @@ func TestКаталогОтвечает500НаОтказХранилища(t *te
if rec.Code != http.StatusInternalServerError {
t.Fatalf("статус %d, ждали 500", rec.Code)
}
if body := rec.Body.String(); strings.Contains(body, "sql") || strings.Contains(body, "bucket") {
t.Errorf("наружу уехали внутренности: %s", body)
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)
}
}