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