Files
healthlog/internal/hae/hae_test.go
T
av 8331328134 Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет
  вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и
  ряд из null больше не затирают маршрут. Победитель внутри доставки стал
  функцией множества версий — общим помощником с точками, — а провенанс
  поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину
  к прежнему содержимому.
- Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной
  записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма
  считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает
  на старте; текст ошибки разбора не несёт значений из тела.
- Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты:
  безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя
  был квадратичен по числу присланных версий одного ключа.
2026-08-02 16:38:18 +03:00

789 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package hae_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"unicode/utf8"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
func load(t *testing.T, name string) []byte {
t.Helper()
body, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("фикстура %s: %v", name, err)
}
return body
}
// layersOf собирает слой по имени метрики: слой обязан выводиться ПО МЕТРИКЕ,
// а не по доставке, поэтому сверять его надо тоже по метрике.
func layersOf(points []hae.Point) map[string]hae.Layer {
out := make(map[string]hae.Layer)
for _, p := range points {
out[p.Metric] = p.Layer
}
return out
}
func countsOf(points []hae.Point) map[string]int {
out := make(map[string]int)
for _, p := range points {
out[p.Metric]++
}
return out
}
// Фикстуры собраны из живого архива с вычищенными значениями: выравнивание
// меток, порядок ключей и форма литералов сохранены (см. testdata/README.md).
func TestParseФикстурыЖивогоПотока(t *testing.T) {
t.Parallel()
cases := []struct {
name string
file string
meta hae.Meta
layer hae.Layer
metrics map[string]hae.Layer
}{
{
name: "минутная доставка",
file: "minute.json",
meta: hae.Meta{Aggregation: "Minutes"},
layer: hae.LayerMinute,
metrics: map[string]hae.Layer{
"active_energy": hae.LayerMinute,
"heart_rate_variability": hae.LayerMinute,
"apple_stand_hour": hae.LayerHour, // плотная и своя
"sleep_analysis_summary": hae.LayerDay, // слой назначен схемой
"six_minute_walking_test_distance": hae.LayerMinute, // редкая наследует
},
},
{
name: "часовая доставка",
file: "hour.json",
meta: hae.Meta{Aggregation: "Hours"},
layer: hae.LayerHour,
metrics: map[string]hae.Layer{
"active_energy": hae.LayerHour,
"apple_stand_hour": hae.LayerHour,
"sleep_analysis_summary": hae.LayerDay,
},
},
{
name: "посекундная доставка",
file: "raw.json",
meta: hae.Meta{Aggregation: "Default"},
layer: hae.LayerRaw,
metrics: map[string]hae.Layer{
"heart_rate": hae.LayerRaw,
"resting_heart_rate": hae.LayerRaw,
},
},
{
// Перенастройка автоматизации: в одной доставке минутные,
// посекундные и часовые метрики. Отнесение доставки к одному слою
// сложило бы минутные точки с посекундными.
name: "смешанная доставка",
file: "mixed.json",
meta: hae.Meta{Aggregation: "Default"},
layer: hae.LayerRaw,
metrics: map[string]hae.Layer{
"heart_rate": hae.LayerRaw,
"active_energy": hae.LayerMinute,
"apple_stand_hour": hae.LayerHour,
"sleep_analysis_summary": hae.LayerDay,
"resting_heart_rate": hae.LayerRaw, // редкая наследует слой доставки
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, c.file), c.meta)
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Layer != c.layer {
t.Errorf("слой доставки = %q, ожидался %q", res.Layer, c.layer)
}
got := layersOf(res.Points)
for metric, want := range c.metrics {
if got[metric] != want {
t.Errorf("%s: слой %q, ожидался %q", metric, got[metric], want)
}
}
if res.SkippedMalformed != 0 {
t.Errorf("живая фикстура дала %d неразобранных точек", res.SkippedMalformed)
}
})
}
}
// Под именем sleep_analysis приезжают две несовместимые схемы. Разделение
// выполняется ДО вывода слоя: сводок бывает больше порога плотности, и их
// полуночные метки иначе назначили бы всей доставке слой hour.
func TestParseРазводитДвеСхемыСна(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "minute.json"), hae.Meta{Aggregation: "Minutes"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
counts := countsOf(res.Points)
if counts["sleep_analysis_summary"] == 0 {
t.Fatal("суточная сводка сна не выделена в собственную метрику")
}
if counts["sleep_analysis"] != 0 {
t.Errorf("под именем sleep_analysis осталось %d точек сводки", counts["sleep_analysis"])
}
for _, p := range res.Points {
if p.Metric == "sleep_analysis_summary" && p.Layer != hae.LayerDay {
t.Errorf("слой сводки = %q, ожидался day", p.Layer)
}
}
}
// Ключ по метке схлопнул бы эти три записи в одну: у них общий date и разные
// интервалы. Живьём такое встречается в 22 доставках из 94 (находка 47).
func TestParseСохраняетЗаписиСОднойМеткой(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
var intervals []hae.Point
for _, p := range res.Points {
if p.Metric == "interval_points" {
intervals = append(intervals, p)
}
}
if len(intervals) != 3 {
t.Fatalf("точек-интервалов %d, ожидалось 3", len(intervals))
}
starts := map[time.Time]int{}
ends := map[time.Time]int{}
for _, p := range intervals {
starts[p.Start]++
ends[p.End]++
if !p.End.After(p.Start) {
t.Errorf("конец %v не позже начала %v", p.End, p.Start)
}
}
if len(starts) != 1 {
t.Errorf("начал %d, ожидалось одно общее", len(starts))
}
if len(ends) != 3 {
t.Errorf("концов %d, ожидалось три разных", len(ends))
}
}
// Точка без end адресуется вырожденным интервалом: ключ одной формы для всех
// точек, без ветвления по «классу метрики».
func TestParseТочкаБезКонцаВырожденнаяВИнтервал(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
found := false
for _, p := range res.Points {
if p.Metric != "mixed_alignment" {
continue
}
found = true
if !p.End.Equal(p.Start) {
t.Fatalf("конец %v не равен началу %v у точки-измерения", p.End, p.Start)
}
}
if !found {
t.Fatal("метрика mixed_alignment не разобрана")
}
}
// Метка ровно на часе одновременно является и минутной, поэтому слой метрики —
// самое мелкое встретившееся выравнивание, а не преобладающее.
func TestParseСлойПоСамомуМелкомуВыравниванию(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if got := layersOf(res.Points)["mixed_alignment"]; got != hae.LayerMinute {
t.Errorf("слой = %q, ожидался minute: десять меток часовые, одна минутная", got)
}
}
// Незнакомое поле — норма: HAE меняется между версиями, и точка обязана
// доехать до хранилища дословно, вместе с тем, чего разбор не понимает.
func TestParseСохраняетТочкуДословно(t *testing.T) {
t.Parallel()
body := load(t, "handmade_edge.json")
res, err := hae.Parse(body, hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
var raw string
for _, p := range res.Points {
if p.Metric == "unknown_shape" {
raw = string(p.Raw)
}
}
if raw == "" {
t.Fatal("метрика unknown_shape не разобрана")
}
for _, want := range []string{
`"inventedField"`, // незнакомое поле целиком
`"nested"`, // вместе с вложенной структурой
`1.0`, // литерал, который round-trip превратил бы в 1
`9007199254740993`, // целое за пределами точности double
`0.095231829624713534`, // дробь длиннее двенадцати значащих цифр
} {
if !strings.Contains(raw, want) {
t.Errorf("в сохранённой точке нет %s:\n%s", want, raw)
}
}
}
// Серия ударов — 93% объёма метрики heart_rate_variability и третий формат
// времени. Разворачивать её в точки нельзя, преобразовывать эпоху — тоже:
// time.Unix и обратно не гарантирует дословности.
func TestParseНеТрогаетСериюУдаров(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "heartbeat_series.json"), hae.Meta{FallbackLayer: hae.LayerRaw})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Points) != 2 {
t.Fatalf("точек %d, ожидалось 2: серия не должна разворачиваться", len(res.Points))
}
var series []struct {
Date json.Number `json:"date"`
}
var point struct {
Series json.RawMessage `json:"heartbeatSeries"`
}
if err := json.Unmarshal(res.Points[0].Raw, &point); err != nil {
t.Fatalf("точка не разбирается: %v", err)
}
if len(point.Series) == 0 {
t.Fatal("в точке нет heartbeatSeries")
}
dec := json.NewDecoder(strings.NewReader(string(point.Series)))
dec.UseNumber()
if err := dec.Decode(&series); err != nil {
t.Fatalf("серия не разбирается: %v", err)
}
if len(series) == 0 {
t.Fatal("серия пуста")
}
// Эпоха внутри серии осталась дробным числом, а не превратилась в строку
// или в целое: разбор её не касался.
if !strings.Contains(series[0].Date.String(), ".") {
t.Errorf("эпоха внутри серии изменена: %s", series[0].Date)
}
}
// Доставка без плотных метрик: измерено 2 такие из 89, обе с заголовком
// Default. Молчаливый выбор raw создал бы призрачный разрез, который поедет в
// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон».
func TestParseДоставкаБезПлотныхМетрик(t *testing.T) {
t.Parallel()
body := load(t, "sparse_sleep.json")
t.Run("наследовать нечего и заголовок ненадёжен", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
if !errors.Is(err, hae.ErrLayerUnknown) {
t.Fatalf("ошибка %v, ожидалась ErrLayerUnknown", err)
}
if len(res.Points) != 0 {
t.Errorf("сохранено %d точек при неизвестном слое", len(res.Points))
}
})
t.Run("наследует слой автоматизации", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default", FallbackLayer: hae.LayerMinute})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Layer != hae.LayerMinute {
t.Errorf("слой = %q, ожидался унаследованный minute", res.Layer)
}
})
t.Run("надёжный заголовок как последнее средство", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(body, hae.Meta{Aggregation: "Hours"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Layer != hae.LayerHour {
t.Errorf("слой = %q, ожидался hour из заголовка", res.Layer)
}
})
}
// Заголовок Default не означает режима, поэтому расхождение с ним не
// фиксируется: сигнал утонул бы в собственном шуме на потоке в пять минут.
func TestParseРасхождениеСлояТолькоПротивНадёжногоЗаголовка(t *testing.T) {
t.Parallel()
body := load(t, "raw.json") // выводится raw
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.LayerMismatch {
t.Error("расхождение зафиксировано против заголовка Default")
}
res, err = hae.Parse(body, hae.Meta{Aggregation: "Hours"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !res.LayerMismatch {
t.Error("расхождение с надёжным заголовком Hours не зафиксировано")
}
if res.Layer != hae.LayerRaw {
t.Errorf("слой = %q: сохранять надо по выведенному, а не по заголовку", res.Layer)
}
}
// Частичный исход — счётчиками, а не ошибкой: пакет, у которого не разобралась
// часть точек, доезжает остальным содержимым.
func TestParseПропускаетНеразобранныеТочки(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.SkippedNoTime != 3 {
t.Errorf("точек без метки %d, ожидалось 3", res.SkippedNoTime)
}
if res.SkippedMalformed != 1 {
t.Errorf("неразобранных точек %d, ожидалась 1", res.SkippedMalformed)
}
if countsOf(res.Points)["broken_timestamps"] != 0 {
t.Error("точка с непонятной меткой попала в хранилище")
}
}
// Время нормализовано в UTC, но зона не потеряна: без офсета неизвестно, в
// каком поясе человек находился.
func TestParseСохраняетСмещениеЗоны(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
for _, p := range res.Points {
if p.Metric != "mixed_alignment" {
continue
}
if p.Start.Location() != time.UTC {
t.Errorf("время не в UTC: %v", p.Start)
}
if p.OffsetSeconds != 3*3600 {
t.Errorf("смещение %d секунд, ожидалось %d", p.OffsetSeconds, 3*3600)
}
return
}
t.Fatal("метрика mixed_alignment не разобрана")
}
// Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не имеет
// права упасть из-за того, что HAE прислал невиданное.
func TestParseВраждебныйВход(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
wantErr error
}{
{"усечённое тело", `{"data":{"metrics":[`, hae.ErrMalformed},
{"не JSON вовсе", `не json`, hae.ErrMalformed},
{"пустое тело", ``, hae.ErrMalformed},
{"null вместо объекта", `null`, nil},
{"массив вместо data", `{"data":[1,2,3]}`, hae.ErrMalformed},
{"массив вместо metrics", `{"data":{"metrics":{}}}`, hae.ErrMalformed},
{"метрики отсутствуют", `{"data":{"workouts":[]}}`, nil},
{"точка не объект", `{"data":{"metrics":[{"name":"m","data":[1,2]}]}}`, nil},
{"число вместо строки даты", `{"data":{"metrics":[{"name":"m","data":[{"date":1}]}]}}`, nil},
{"дата не того формата", `{"data":{"metrics":[{"name":"m","data":[{"date":"2025-06-05T10:00:00Z"}]}]}}`, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, err := hae.Parse([]byte(c.body), hae.Meta{})
if c.wantErr == nil {
if err != nil && !errors.Is(err, hae.ErrLayerUnknown) {
t.Fatalf("неожиданная ошибка: %v", err)
}
return
}
if !errors.Is(err, c.wantErr) {
t.Fatalf("ошибка %v, ожидалась %v", err, c.wantErr)
}
})
}
}
// Разбор — чистая функция: ни времени, ни генерации идентификаторов. Два
// вызова на одном входе обязаны совпасть, иначе пересборка витрины из архива
// даст не то состояние, что живой приём.
func TestParseДетерминирован(t *testing.T) {
t.Parallel()
body := load(t, "mixed.json")
meta := hae.Meta{Aggregation: "Default"}
a, err := hae.Parse(body, meta)
if err != nil {
t.Fatalf("первый разбор: %v", err)
}
b, err := hae.Parse(body, meta)
if err != nil {
t.Fatalf("второй разбор: %v", err)
}
if len(a.Points) != len(b.Points) {
t.Fatalf("точек %d против %d", len(a.Points), len(b.Points))
}
for i := range a.Points {
if a.Points[i].Metric != b.Points[i].Metric ||
!a.Points[i].Start.Equal(b.Points[i].Start) ||
!a.Points[i].End.Equal(b.Points[i].End) ||
a.Points[i].Layer != b.Points[i].Layer ||
string(a.Points[i].Raw) != string(b.Points[i].Raw) {
t.Fatalf("точка %d разошлась между вызовами", i)
}
}
}
// Тело не должно портиться разбором: оно уже лежит в архиве, и пересборка
// прочитает те же байты.
func TestParseНеПортитТело(t *testing.T) {
t.Parallel()
body := load(t, "mixed.json")
before := string(body)
if _, err := hae.Parse(body, hae.Meta{Aggregation: "Default"}); err != nil {
t.Fatalf("разбор: %v", err)
}
if string(body) != before {
t.Error("разбор изменил тело доставки")
}
}
func FuzzParse(f *testing.F) {
for _, name := range []string{"minute.json", "sparse_sleep.json", "handmade_edge.json"} {
body, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
f.Fatalf("фикстура %s: %v", name, err)
}
f.Add(body)
}
f.Add([]byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[{"date":"2025-06-05 10:00:00 +0300"}]}]}}`))
f.Fuzz(func(t *testing.T, body []byte) {
// Единственное требование: разбор произвольного входа не роняет
// процесс и не отдаёт точку без координат.
res, err := hae.Parse(body, hae.Meta{})
if err != nil {
return
}
for _, p := range res.Points {
if p.Start.IsZero() {
t.Fatal("точка без метки времени доехала до хранилища")
}
if p.End.Before(p.Start) {
t.Fatalf("конец раньше начала: %v < %v", p.End, p.Start)
}
if p.Layer == "" {
t.Fatal("точка без слоя доехала до хранилища")
}
}
})
}
// Непокрытая секция неотличима от разобранной доставки с пустой секцией
// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
// тела, которые для секций без экспорта Apple единственный источник. С тех пор
// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
// играют секции, которых поток ещё не приносил.
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "uncovered_sections.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
want := []string{"ecg", "symptoms"}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
}
if len(res.Points) == 0 {
t.Error("точки метрик обязаны сохраниться: непокрытая секция рядом им не мешает")
}
if res.UncoveredDropped != 0 {
t.Errorf("отброшено %d имён, ожидалось 0", res.UncoveredDropped)
}
}
func TestParseНепокрытыеСекцииГраницыИДетерминизм(t *testing.T) {
t.Parallel()
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !slices.Equal(res.Uncovered, []string{"ecg"}) {
t.Errorf("непокрытые %v", res.Uncovered)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
t.Run("доставка из одних метрик списка не даёт", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse([]byte(`{"data":{"metrics":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 0 {
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
}
})
// Порядок ключей в JSON от HAE нестабилен, а список уезжает в базу и
// сравнивается между доставками: зависящий от порядка на проводе список
// сравнивать нельзя.
t.Run("порядок и повторы не влияют", func(t *testing.T) {
t.Parallel()
bodies := []string{
`{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
`{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
`{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
}
want := []string{"ecg", "medications", "symptoms"}
for _, b := range bodies {
res, err := hae.Parse([]byte(b), hae.Meta{})
if err != nil {
t.Fatalf("разбор %s: %v", b, err)
}
if !slices.Equal(res.Uncovered, want) {
t.Errorf("тело %s дало %v, ожидалось %v", b, res.Uncovered, want)
}
}
})
// Повтор ключа metrics JSON допускает; секции объединяются, а не побеждает
// последняя — терять точки молча нельзя.
t.Run("повтор metrics объединяет секции", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[]}],"metrics":[{"name":"b","data":[]}]}}`
res, err := hae.Parse([]byte(body), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Metrics != 2 {
t.Errorf("метрик %d, ожидалось 2 — секции обязаны объединиться", res.Metrics)
}
})
t.Run("границы списка видны", func(t *testing.T) {
t.Parallel()
var sb strings.Builder
sb.WriteString(`{"data":{`)
for i := range 40 {
if i > 0 {
sb.WriteByte(',')
}
fmt.Fprintf(&sb, `"s%02d":[]`, i)
}
sb.WriteString(`}}`)
res, err := hae.Parse([]byte(sb.String()), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 32 {
t.Errorf("имён %d, ожидалось 32", len(res.Uncovered))
}
if res.UncoveredDropped != 8 {
t.Errorf("отброшено %d, ожидалось 8 — иначе «ровно 32» неотличимо от «пришло пятьсот»", res.UncoveredDropped)
}
})
t.Run("длинное имя обрезано и помечено", func(t *testing.T) {
t.Parallel()
long := strings.Repeat("щ", 60) // 120 байт
res, err := hae.Parse([]byte(`{"data":{"`+long+`":[]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Uncovered) != 1 {
t.Fatalf("непокрытые %v", res.Uncovered)
}
got := res.Uncovered[0]
if !strings.HasSuffix(got, "…") {
t.Errorf("имя %q без маркера обрезки: обрезка не инъективна и обязана быть видимой", got)
}
if !utf8.ValidString(got) {
t.Errorf("имя %q обрезано посреди руны", got)
}
})
// Ошибка после разобранной секции metrics: правило прежнее — при ошибке
// точек нет вовсе, иначе часть точек оказалась бы в витрине под статусом,
// по которому доставку никто не подберёт.
t.Run("обрыв после метрик точек не отдаёт", func(t *testing.T) {
t.Parallel()
const body = `{"data":{"metrics":[{"name":"a","data":[{"date":"2025-06-05 10:00:00 +0300","qty":1}]}],"work`
res, err := hae.Parse([]byte(body), hae.Meta{})
if !errors.Is(err, hae.ErrMalformed) {
t.Fatalf("ошибка %v, ожидалась ErrMalformed", err)
}
if len(res.Points) != 0 {
t.Errorf("точек %d, ожидалось 0", len(res.Points))
}
})
}
// Инвариант «тела запросов только на DEBUG и с обрезкой» обходился одним `%v`:
// строка в 8 МиБ на месте ожидаемого объекта давала текст ошибки в 8 МиБ, и он
// уезжал атрибутом `error` на уровень WARN — то есть содержимое доставки
// оказывалось в логе целиком и без обрезки.
//
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
// живёт в другом месте и о новой ошибке разбора не узнает.
func TestParseОшибкаНеНесётЗначенийИзТела(t *testing.T) {
t.Parallel()
const secret = "СЕКРЕТНОЕ-ЗНАЧЕНИЕ-ИЗ-ТЕЛА"
cases := map[string]string{
"строка вместо data": `{"data":"` + secret + strings.Repeat("A", 1<<20) + `"}`,
"строка вместо тела": `"` + secret + strings.Repeat("A", 1<<20) + `"`,
"число вместо имени секции": `{"data":{"metrics":[]},"` + secret + `":1}`,
"число вместо содержимого": `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := hae.Parse([]byte(body), hae.Meta{})
if err == nil {
t.Skip("вход разобрался — проверять нечего")
}
msg := err.Error()
if len(msg) > 512 {
t.Errorf("текст ошибки %d Б: длина зависит от длины значения во входе", len(msg))
}
if strings.Contains(msg, secret) {
t.Errorf("значение из тела доехало до сообщения: %s", msg)
}
})
}
}
// Смещение обязано указывать на место ПЕРЕД виновным токеном, а не за ним.
// Декодер сообщает позицию как конец последнего возвращённого токена, поэтому
// снятая ПОСЛЕ чтения она отличалась бы от начала проблемы ровно на длину
// значения — на восемь мегабайт в том самом случае, ради которого требование и
// написано. Проверяется само свойство: смещение не зависит от длины значения.
func TestParseОшибкаНазываетТипТокенаИНачало(t *testing.T) {
t.Parallel()
short := `{"data":"` + strings.Repeat("A", 16) + `"}`
long := `{"data":"` + strings.Repeat("A", 1<<20) + `"}`
msgs := make([]string, 0, 2)
for _, body := range []string{short, long} {
_, err := hae.Parse([]byte(body), hae.Meta{})
if err == nil {
t.Fatal("ожидалась ошибка")
}
msgs = append(msgs, err.Error())
}
if !strings.Contains(msgs[0], "string") {
t.Errorf("тип токена не назван словарём JSON: %s", msgs[0])
}
// `{"data"` — семь байт: разбор виновного значения начинается здесь.
if !strings.Contains(msgs[0], "смещении 7") {
t.Errorf("смещение не указывает на место перед токеном: %s", msgs[0])
}
if msgs[0] != msgs[1] {
t.Errorf("смещение поехало вместе с длиной значения:\n %s\n %s", msgs[0], msgs[1])
}
}
// Ошибка может прийти не только от нашего разбора, но и из encoding/json, а его
// UnmarshalTypeError кладёт в текст ЛИТЕРАЛ значения: тело из миллиона цифр
// давало текст ошибки в мегабайт. Предел держится на границе пакета.
func TestParseЧужаяПричинаОбрезается(t *testing.T) {
t.Parallel()
body := `{"data":{"metrics":` + strings.Repeat("9", 1<<20) + `}}`
_, err := hae.Parse([]byte(body), hae.Meta{})
if err == nil {
t.Fatal("ожидалась ошибка")
}
if len(err.Error()) > 512 {
t.Errorf("текст ошибки %d Б: литерал из тела доехал до сообщения", len(err.Error()))
}
}