непокрытые секции доставки видны в статусе разбора

- половина потока (50 доставок из 104) не несёт metrics вовсе и до сих пор
  числилась parsed: ретеншен, поверив статусу, срезал бы тела stateOfMind,
  которых в экспорте Apple нет
- разбор перечисляет верхнеуровневые ключи data, непокрытые проглатываются
  декодированием: тело 40 МиБ из непокрытой секции удерживает 0 МиБ
- статус partial и колонка delivery.uncovered_sections; миграция переводит
  прежние parsed в pending — им верить нельзя
- витрина не изменилась: отпечаток совпал с прогоном до изменения
This commit is contained in:
av
2026-08-01 21:25:59 +03:00
parent 7a7594e3e7
commit 34e5109b6d
26 changed files with 1808 additions and 89 deletions
+157
View File
@@ -3,11 +3,14 @@ package hae_test
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"unicode/utf8"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
@@ -544,3 +547,157 @@ func FuzzParse(f *testing.F) {
}
})
}
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал
// бы тела, которые для 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{"stateOfMind", "workouts"}
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":{"stateOfMind":[{"x":1}]}}`), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) {
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":{"workouts":[],"stateOfMind":[],"ecg":[]}}`,
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`,
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`,
}
want := []string{"ecg", "stateOfMind", "workouts"}
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))
}
})
}