- `GET /api/v1/metrics/{name}?from&to&layer` — ряд точек за период; конверт
объявляет слой, измеренный род, его применимость к отданному ряду и границу
окна измерения, а сам ряд собирается из одного слоя, выбранного по охвату
точек внутри периода
- use-case вынесен в `internal/points`, чтение — одним входом `store.ReadSeries`
под одной транзакцией; правило выбора слоя остаётся в домене и приходит в
хранилище колбэком
- `writeJSON` перестал экранировать HTML-символы и перестал глушить отказ
записи: дословность содержимого точки иначе не удерживается, а оборванное
тело уходило под видом успешного `200`
225 lines
12 KiB
Go
225 lines
12 KiB
Go
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{},
|
|
"точки": pointsResponse{},
|
|
"тело отказа": 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)
|
|
}
|
|
}
|