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
+33 -21
View File
@@ -63,12 +63,10 @@ func (s Style) String() string {
}
}
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
// словаре нет.
func (s Style) MarshalJSON() ([]byte, error) {
return []byte(`"` + s.String() + `"`), nil
}
// Собственной сериализации у Style нет намеренно: строку в ответ кладёт
// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
// сообщениям тестов, и проводу — второго словаря заводить незачем.
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
// потому что от них зависят счётчики основания в ответе.
@@ -156,6 +154,16 @@ func clipMetric(metric string) string {
return metric[:maxMetricInLog] + "…"
}
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
//
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
// «Read API», подраздел «Форма провода».
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
// разности были осмысленны: `Hours Compared` — часы, отброшенные проверкой
// пригодности, `Compared Agreeing Conflicting` — часы, не сошедшиеся ни с
@@ -165,17 +173,21 @@ func clipMetric(metric string) string {
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
// второго запроса.
type Basis struct {
Hours int `json:"hours"`
Compared int `json:"compared"`
Agreeing int `json:"agreeing"`
Conflicting int `json:"conflicting"`
FirstHour *time.Time `json:"first_hour"`
LastHour *time.Time `json:"last_hour"`
Hours int
Compared int
Agreeing int
Conflicting int
FirstHour *time.Time
LastHour *time.Time
}
// Aggregation — род вместе с основанием.
//
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
// `aggregation` на проводе объявлена транспортом поимённо и от этого
// встраивания не зависит.
type Aggregation struct {
Style Style `json:"style"`
Style Style
Basis
}
@@ -186,22 +198,22 @@ type Aggregation struct {
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
// знает.
type LayerRange struct {
Layer string `json:"layer"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Points int `json:"points"`
Layer string
From time.Time
To time.Time
Points int
}
// Metric — запись каталога.
type Metric struct {
Metric string `json:"metric"`
Metric string
// Units — множество различных единиц метрики, отсортированное. Массив, а не
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
// для обоих случаев честнее строки, которая при расхождении молча выберет
// одно из двух.
Units []string `json:"units"`
Aggregation Aggregation `json:"aggregation"`
Layers []LayerRange `json:"layers"`
Units []string
Aggregation Aggregation
Layers []LayerRange
}
// Snapshot — каталог вместе с версией ответа.
+13 -10
View File
@@ -271,22 +271,25 @@ func TestMeasureПротиворечиеСПеревесомМгновенной
}
}
// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род
// уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же
// `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на
// проводе разошёлся бы с этим молча.
//
// Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен
// видеть состояние, которого в словаре нет.
func TestStyleСловарь(t *testing.T) {
t.Parallel()
cases := map[catalog.Style]string{
catalog.Cumulative: `"cumulative"`,
catalog.Instant: `"instant"`,
catalog.Unknown: `"unknown"`,
catalog.Style(42): `"unknown"`,
catalog.Cumulative: "cumulative",
catalog.Instant: "instant",
catalog.Unknown: "unknown",
catalog.Style(42): "unknown",
}
for style, want := range cases {
got, err := json.Marshal(style)
if err != nil {
t.Fatalf("сериализация %v: %v", style, err)
}
if string(got) != want {
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
if got := style.String(); got != want {
t.Errorf("род %d: получили %q, ждали %q", style, got, want)
}
}
}
+105 -8
View File
@@ -2,26 +2,123 @@ package httpapi
import (
"net/http"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
)
// ФОРМА ПРОВОДА КАТАЛОГА. Публичный контракт объявлен здесь и только здесь —
// доменные типы `internal/catalog` `json`-тегов не несут и до сериализации не
// доезжают. Отсюда следует то, ради чего это сделано: переименование поля в
// домене байты ответа не меняет, а смена контракта есть правка вот этих
// объявлений, то есть действие, а не побочный эффект.
//
// Обратная цена взята сознательно: новое поле домена в ответ само не попадёт —
// его обязан перечислить перевод ниже. Решение, цена обеих сторон и разбор
// чужих решений — `docs/architecture.md`, раздел «Read API», подраздел «Форма
// провода».
//
// ОБРАЗЕЦ ДЛЯ СЛЕДУЮЩИХ МАРШРУТОВ: точки, тренировки, записи и MCP объявляют
// свою форму так же — типами рядом с обработчиком, переводом-присваиванием,
// строкой в таблице `wire_internal_test.go`. Выбирать заново не нужно.
// catalogResponse — оболочка ответа каталога.
//
// Объект, а не голый массив: список метрик — не единственное, что каталогу
// когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем.
type catalogResponse struct {
Metrics []catalog.Metric `json:"metrics"`
Metrics []metricWire `json:"metrics"`
}
// metricWire — запись каталога на проводе.
type metricWire struct {
Metric string `json:"metric"`
Units []string `json:"units"`
Aggregation aggregationWire `json:"aggregation"`
Layers []layerWire `json:"layers"`
}
// aggregationWire — род агрегации вместе с основанием, ПЛОСКО.
//
// Поля основания перечислены здесь поимённо, а не встроены структурой: в домене
// `Basis` встроен в `Aggregation`, и плоскость объекта была следствием этого
// встраивания — разъединение в домене молча дало бы клиенту вложенный объект.
// Теперь плоскость — записанное решение транспорта.
//
// Род — обычная `string`: словарь (`cumulative`/`instant`/`unknown`) клиент
// видит строкой, и кладёт её сюда транспорт. Значения словаря при этом остаются
// доменные (`catalog.Style.String()`) — свой `switch` здесь завёл бы второй
// словарь, который разошёлся бы с первым молча.
type aggregationWire struct {
Style string `json:"style"`
Hours int `json:"hours"`
Compared int `json:"compared"`
Agreeing int `json:"agreeing"`
Conflicting int `json:"conflicting"`
FirstHour *time.Time `json:"first_hour"`
LastHour *time.Time `json:"last_hour"`
}
// layerWire — разрез метрики на проводе.
type layerWire struct {
Layer string `json:"layer"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Points int `json:"points"`
}
// catalogWire переводит записи домена в форму провода.
//
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`, и снимок у тела и у
// метки один, иначе `304` подтверждал бы одно состояние, а `200` отдавал другое.
//
// Присваивание поле в поле, а не копирование структуры: это и есть то место, где
// смена контракта становится видимой правкой.
func catalogWire(metrics []catalog.Metric) catalogResponse {
// Непустой срез, а не nil: nil сериализуется в `null`, и пустая витрина
// отдавала бы клиенту `"metrics": null` вместо `[]`. Домен это уже
// обеспечивает, но контракт объявлен здесь — значит и держится здесь.
out := make([]metricWire, 0, len(metrics))
for _, m := range metrics {
layers := make([]layerWire, 0, len(m.Layers))
for _, l := range m.Layers {
layers = append(layers, layerWire{
Layer: l.Layer,
From: l.From,
To: l.To,
Points: l.Points,
})
}
// Так же, как слои: `make` + `append`, а не копирование среза домена.
// Ветвления «если nil» здесь нет намеренно — оно было бы веткой, которую
// сегодня не проходит ни один вход (домен nil не отдаёт), то есть
// непроверяемой страховкой. Конструкция даёт непустой срез всегда.
units := make([]string, 0, len(m.Units))
units = append(units, m.Units...)
out = append(out, metricWire{
Metric: m.Metric,
Units: units,
Aggregation: aggregationWire{
Style: m.Aggregation.Style.String(),
Hours: m.Aggregation.Hours,
Compared: m.Aggregation.Compared,
Agreeing: m.Aggregation.Agreeing,
Conflicting: m.Aggregation.Conflicting,
// Указатели переносятся КАК УКАЗАТЕЛИ: разыменование дало бы
// `0001-01-01T00:00:00Z` там, где окно не измерено, а
// правдоподобная дата в ответе неотличима от настоящей.
FirstHour: m.Aggregation.FirstHour,
LastHour: m.Aggregation.LastHour,
},
Layers: layers,
})
}
return catalogResponse{Metrics: out}
}
// handleMetrics отдаёт каталог разрезов с измеренным родом агрегации.
//
// Список приходит из домена уже непустым срезом: nil сериализуется в `null`, и
// пустая витрина отдавала бы клиенту `"metrics": null` вместо `[]`. Тест,
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
// подстраховка поверх подстраховки прячет отказ первой.
//
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
@@ -62,5 +159,5 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
// ответ уходит без метки: это ровно поведение до появления условного
// запроса, то есть деградация в безопасную сторону.
setReadHeaders(w, etag(scopeMetrics, snap.Version))
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
}
+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)
}
}
+15 -1
View File
@@ -158,6 +158,20 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
_ = json.NewEncoder(w).Encode(v)
}
// errorWire — форма провода тела отказа, общая для всех маршрутов.
//
// Объявленный тип, а не `map[string]string`: у читающего маршрута тело отказа
// такая же часть публичного контракта, как и успешный ответ, и клиент видит его
// чаще. Карта же делает «два ответа совпадают побайтово» свойством библиотеки, а
// не решения, и переименование ключа `error` не увидел бы ни один сторож — ни
// обход графа типов (карта строк проходит как стандартный тип), ни байтовый
// литерал (тел отказа он не закреплял).
type errorWire struct {
Error string `json:"error"`
}
// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте
// имена колонок и форма запроса.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
writeJSON(w, status, errorWire{Error: msg})
}
+223
View File
@@ -0,0 +1,223 @@
package httpapi
import (
"encoding/json"
"reflect"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/catalog"
)
// Форма провода объявлена транспортом, и это утверждается ПРЯМО: ни один тип
// домена не достигает сериализации ответа.
//
// Отсюда и следует свойство, ради которого задача существует: переименование
// поля доменного типа байты ответа не меняет — до энкодера этот тип не доезжает
// вовсе. Прозой это уже было написано; прозу компилятор не проверяет.
//
// Прямого «переименуй и посмотри» в Go-тесте не бывает: отказ компиляции
// собственного пакета тест не наблюдает, а при неудавшейся компиляции байтов не
// существует. Поэтому утверждается эквивалентное и проверяемое — недостижимость
// домена из графа типов ответа.
// modulePrefix — префикс путей пакетов этого модуля.
//
// Сверка идёт по `PkgPath`, а не по имени пакета: имя совпадает у чужих
// пакетов, а алиас (`type wire = catalog.Metric`) имени вообще не меняет. Если
// префикс протухнет (переезд модуля), проверка станет вечно зелёной — от этого
// и стоит рядом заведомо красный случай.
const modulePrefix = "git.vakhrushev.me/av/healthlog/"
// selfPkg — сам транспорт: его типы и есть объявленная форма провода.
var selfPkg = reflect.TypeOf(catalogResponse{}).PkgPath()
// foreignTypes обходит граф типов значения и собирает типы этого модуля,
// объявленные ВНЕ транспорта.
//
// Обход рекурсивный и покрывает все позиции, в которых доменный тип может
// спрятаться: поле структуры (в том числе неэкспортированное и встроенное),
// элемент среза и массива, ключ И значение карты, адресат указателя. `seen`
// защищает от самоссылающегося типа, а не оптимизирует.
//
// Исключение одно и оно задано ПО ТИПУ — `json.RawMessage`: дословно
// сохранённое содержимое уезжает клиенту сырым JSON (инвариант «точки хранятся
// дословно»). Признак «у типа есть свой `MarshalJSON`» исключением быть не мог:
// он вернул бы домен на провод ровно тем механизмом, который сняли со `Style`.
func foreignTypes(t reflect.Type) []string {
var out []string
seen := map[reflect.Type]bool{}
var walk func(reflect.Type)
walk = func(t reflect.Type) {
if t == nil || seen[t] {
return
}
seen[t] = true
if t == reflect.TypeOf(json.RawMessage(nil)) {
return
}
if p := t.PkgPath(); strings.HasPrefix(p, modulePrefix) && p != selfPkg {
out = append(out, t.String()+" ← "+p)
return
}
// Своя структура обязана объявить имя КАЖДОГО экспортированного поля
// тегом. Без этого требования в обход есть тихая калитка: определённый
// тип поверх доменной структуры (`type pointWire store.Point`) числится
// транспортным по `PkgPath`, обход через него не идёт — а ключи ответа
// оказываются именами полей домена, то есть ровно та связь, которую
// задача разрывала. Поймано проходом `specs` на профиле `deep`.
// Пустой `PkgPath` — безымянная структура: она тоже часть формы провода,
// раз доехала сюда по графу, и правило на неё распространяется.
if t.Kind() == reflect.Struct && (t.PkgPath() == selfPkg || t.PkgPath() == "") {
for i := range t.NumField() {
f := t.Field(i)
if f.PkgPath != "" || f.Anonymous {
continue // неэкспортированное `encoding/json` не пишет
}
if _, ok := f.Tag.Lookup("json"); !ok {
out = append(out, t.String()+"."+f.Name+" — поле формы провода без json-тега")
}
}
}
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array:
walk(t.Elem())
case reflect.Map:
walk(t.Key())
walk(t.Elem())
case reflect.Struct:
for i := range t.NumField() {
walk(t.Field(i).Type)
}
}
}
walk(t)
return out
}
// Таблица образцов ответа. СЛЕДУЮЩИЙ МАРШРУТ ДОБАВЛЯЕТ СЮДА СТРОКУ — точки,
// тренировки, записи, статистика. Выбирать решение заново не нужно.
func TestФормаПроводаДоменаНеСодержит(t *testing.T) {
cases := map[string]any{
"каталог": catalogResponse{},
"тело отказа": errorWire{},
"учёт приёма": ingestResponse{},
}
for name, sample := range cases {
t.Run(name, func(t *testing.T) {
if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) > 0 {
t.Errorf("доменные типы в графе ответа: %v", bad)
}
})
}
}
// metricAlias — алиас доменного типа, объявленный в транспорте. Существует
// только ради отрицательного контроля ниже.
type metricAlias = catalog.Metric
// layerDefined — ОПРЕДЕЛЁННЫЙ тип поверх доменной структуры (не алиас): пакет у
// него транспортный, поля — доменные и без тегов. Тоже только для контроля.
type layerDefined catalog.LayerRange
// Заведомо красный случай. Проверка, доказывающая ОТСУТСТВИЕ, зелена и будучи
// сломанной: протухший `modulePrefix`, пропущенная позиция обхода, перепутанное
// сравнение — всё это выглядит как «доменных типов нет». В проекте этот класс
// уже дважды всплывал (docs/conventions/testing.md: отрицательный контроль для
// правил порядка, утверждение о таблице-константе).
func TestОбходГрафаТиповНаходитДомен(t *testing.T) {
type embedded struct{ catalog.Aggregation }
cases := map[string]any{
"поле": struct{ M catalog.Metric }{},
"неэкспортированное": struct{ m catalog.Metric }{}, //nolint:unused // позиция обхода, а не поле
"срез": struct{ M []catalog.Metric }{},
"массив": struct{ M [2]catalog.LayerRange }{},
"указатель": struct{ M *catalog.Metric }{},
"значение карты": struct{ M map[string]catalog.Metric }{},
"ключ карты": struct{ M map[catalog.Style]int }{},
"встроенное": embedded{},
"вложенная анонимная": struct{ Inner struct{ M catalog.Metric } }{},
// Алиас — самая тихая позиция: имя пакета у него транспортное, а
// `PkgPath` доменный. Обход, сверяющий имя пакета, пропустил бы её.
"алиас": struct{ M metricAlias }{},
"перечисление": struct{ S catalog.Style }{},
// Определённый тип поверх доменной структуры: по `PkgPath` он
// транспортный, обход внутрь не идёт, а ключи ответа — имена полей
// домена. Ловится требованием тега на каждом экспортированном поле.
"определённый тип поверх домена": layerDefined{},
"поле формы провода без тега": struct{ M string }{},
}
for name, sample := range cases {
t.Run(name, func(t *testing.T) {
if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) == 0 {
t.Error("обход не нашёл доменный тип — проверка ничего не доказывает")
}
})
}
}
// Перевод отдаёт пустые коллекции списком, а не отсутствием, — на входе, где
// домен отдал `nil`.
//
// Утверждение живёт здесь, а не в маршрутном тесте: домен сегодня `nil` не
// отдаёт, поэтому через HTTP этот вход недостижим, а правило принадлежит
// проводу и обязано держаться независимо от того, что домен обещает сейчас.
// Четыре следующих маршрута копируют именно `catalogWire`.
func TestПереводОтдаётПустыеКоллекцииСписком(t *testing.T) {
got, err := json.Marshal(catalogWire([]catalog.Metric{{Metric: "vo2_max"}}))
if err != nil {
t.Fatalf("сериализация: %v", err)
}
want := `{"metrics":[{"metric":"vo2_max","units":[],` +
`"aggregation":{"style":"unknown","hours":0,"compared":0,"agreeing":0,` +
`"conflicting":0,"first_hour":null,"last_hour":null},"layers":[]}]}`
if string(got) != want {
t.Errorf("пустые коллекции:\n получили %s\n ждали %s", got, want)
}
}
// Пустой каталог — `[]`, а не `null`: nil-срез сериализуется в `null`, и
// клиент прочитал бы «поля нет» вместо «метрик нет».
func TestПереводПустогоКаталогаОтдаётСписок(t *testing.T) {
got, err := json.Marshal(catalogWire(nil))
if err != nil {
t.Fatalf("сериализация: %v", err)
}
if string(got) != `{"metrics":[]}` {
t.Errorf("пустой каталог: получили %s", got)
}
}
// Самоссылающийся тип обход не зацикливает: без `seen` это бесконечная
// рекурсия, а не медленный тест.
func TestОбходГрафаТиповНеЗацикливается(t *testing.T) {
type node struct {
Next *node `json:"next"`
At time.Time `json:"at"`
}
if bad := foreignTypes(reflect.TypeOf(node{})); len(bad) > 0 {
t.Errorf("чужие типы: %v", bad)
}
}
// Стандартная библиотека проводу разрешена, и это надо утверждать: правило
// звучит как «домена в графе нет», а не «в графе нет ничего чужого».
// `json.RawMessage` — то самое исключение по типу, ради которого существует
// инвариант «точки хранятся дословно».
func TestОбходГрафаТиповПропускаетСтандартныеТипы(t *testing.T) {
sample := struct {
At time.Time `json:"at"`
Ptr *time.Time `json:"ptr"`
Raw json.RawMessage `json:"raw"`
Units []string `json:"units"`
}{}
if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) > 0 {
t.Errorf("стандартные типы объявлены чужими: %v", bad)
}
}