- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
789 lines
30 KiB
Go
789 lines
30 KiB
Go
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()))
|
||
}
|
||
}
|