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