Дозакрыты находки ревью по слиянию сущностей
- Правило покрытия получило второй разряд (условный, как у точек), запрет вырождения формы и счёт содержательных элементов ряда: скелет из скаляров и ряд из null больше не затирают маршрут. Победитель внутри доставки стал функцией множества версий — общим помощником с точками, — а провенанс поднимается и при совпавшем хеше, иначе отложенная доставка возвращала витрину к прежнему содержимому. - Одно поле не того типа больше не уносит сущность, а пропуски видны в учётной записи доставки (миграция 00008, NULL = «не измерялось»); каноническая форма считается один раз и вне транзакции; откат бинаря поверх новой схемы отказывает на старте; текст ошибки разбора не несёт значений из тела. - Ревью кода профилем deep (девять проходов) нашло две регрессии и обе закрыты: безусловный второй разряд запирал законный досчёт навсегда, а выбор победителя был квадратичен по числу присланных версий одного ключа.
This commit is contained in:
+95
-15
@@ -19,18 +19,67 @@ const maxEntityID = 128
|
||||
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
|
||||
const rfc3339Layout = time.RFC3339
|
||||
|
||||
// softString — строка заголовка, которая переживает значение не того типа.
|
||||
//
|
||||
// Значение не того ТИПА стоит одного поля, а не сущности. Правило уже записано
|
||||
// рядом для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не
|
||||
// сломанная сущность»); без него `name`, приехавшее числом, уносит тренировку
|
||||
// вместе с маршрутом — а доставка при этом числится разобранной, и ретеншен
|
||||
// получает ответ «терять нечего» ровно там, где потеряно 95% содержимого.
|
||||
//
|
||||
// Через json.Unmarshaler, а не через разбор ошибки постфактум. Соблазн есть:
|
||||
// encoding/json при несовпадении типа «skips that field and completes the
|
||||
// unmarshaling as best it can» и возвращает *UnmarshalTypeError, то есть
|
||||
// трёхстрочный errors.As выглядел бы равноценным. Он неравноценен — та же
|
||||
// документация оговаривает, что дозаполнение полей ПОСЛЕ проблемного не
|
||||
// гарантировано. Разбор, построенный на этом, перестал бы быть функцией тела:
|
||||
// одна и та же тренировка давала бы разный заголовок в зависимости от порядка
|
||||
// ключей на проводе, а он у HAE нестабилен.
|
||||
//
|
||||
// Различение счётчиков сохраняется само: элемент, который сам не объект, даёт
|
||||
// ошибку ВЕРХНЕГО уровня и по-прежнему уходит в «не разобралось как объект», а
|
||||
// не в «нет id».
|
||||
// Признак `present` отличает «ключа не было» от «ключ был, но строки из него не
|
||||
// вышло». Различие нужно ровно одному полю — метке начала, — и там оно
|
||||
// существенно: см. фолбэк `start → date` ниже.
|
||||
//
|
||||
// Именно «ключ был», а не «значение не той формы»: `null` тоже даёт пустую
|
||||
// строку, и без этого различения `{"date":"…","start":null}` уводил бы
|
||||
// тренировку на момент времени из другого поля — молча и без счётчика.
|
||||
type softString struct {
|
||||
value string
|
||||
// present — ключ присутствовал в объекте. UnmarshalJSON зовётся только на
|
||||
// присутствующий ключ, поэтому признак взводится безусловно.
|
||||
present bool
|
||||
}
|
||||
|
||||
func (s *softString) UnmarshalJSON(raw []byte) error {
|
||||
// Приёмник задаётся ЦЕЛИКОМ, а не дописывается. JSON допускает повтор
|
||||
// ключа, и encoding/json зовёт UnmarshalJSON на каждое вхождение с
|
||||
// семантикой «побеждает последнее» — так работает соседний Duration и весь
|
||||
// разбор метрик. Накопленный признак сделал бы разбор функцией не тела, а
|
||||
// истории вызовов: `{"start":123,"start":"2025-06-05 …"}` терял бы
|
||||
// тренировку с маршрутом при валидной последней метке.
|
||||
*s = softString{present: true}
|
||||
|
||||
var v string
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
// Значение не строка — поле считается непрочитанным. Ошибку глушим
|
||||
// сознательно: это и есть мягкость, ради которой тип заведён.
|
||||
return nil
|
||||
}
|
||||
s.value = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
|
||||
// хранится дословно.
|
||||
//
|
||||
// Длительность читается сырым сообщением, а не числом: нечисловое значение —
|
||||
// это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле
|
||||
// уводило бы всю тренировку в счётчик «не разобралась как объект».
|
||||
type entityHead struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Date string `json:"date"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
ID softString `json:"id"`
|
||||
Name softString `json:"name"`
|
||||
Date softString `json:"date"`
|
||||
Start softString `json:"start"`
|
||||
End softString `json:"end"`
|
||||
|
||||
Duration json.RawMessage `json:"duration"`
|
||||
}
|
||||
@@ -47,17 +96,41 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
|
||||
|
||||
out := make([]Entity, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
// Род элемента проверяется ДО разбора, потому что `json.Unmarshal`
|
||||
// «null» в структуру ошибкой не считает (для JSON null это no-op) — и
|
||||
// элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся
|
||||
// форма СЕКЦИИ диагностировалась бы как сменившаяся форма
|
||||
// ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия.
|
||||
if !isJSONObject(raw) {
|
||||
res.SkippedEntityMalformed++
|
||||
continue
|
||||
}
|
||||
var head entityHead
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
res.SkippedEntityMalformed++
|
||||
continue
|
||||
}
|
||||
if head.ID == "" || len(head.ID) > maxEntityID {
|
||||
// Идентификатор исключение из мягкости: без строкового `id` сущность не
|
||||
// адресуема, а приведение чужого нестрокового значения к строке было бы
|
||||
// выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение
|
||||
// уже превратило в пустую строку — исход тот же, что у отсутствующего.
|
||||
id := head.ID.value
|
||||
if id == "" || len(id) > maxEntityID {
|
||||
res.SkippedNoID++
|
||||
continue
|
||||
}
|
||||
|
||||
start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date))
|
||||
// Фолбэк `start → date` существует для сущностей, у которых ключа
|
||||
// `start` НЕТ ВОВСЕ. Если ключ пришёл, но строки из него не вышло
|
||||
// (число, объект, `null`), фолбэк не срабатывает: композиция двух
|
||||
// правил подставила бы метку ДРУГОГО момента времени — неотличимую от
|
||||
// настоящей и ничем не считаемую. Такой `start` считается неразбираемой
|
||||
// меткой.
|
||||
if head.Start.present && head.Start.value == "" {
|
||||
res.SkippedEntityNoTime++
|
||||
continue
|
||||
}
|
||||
start, ok := parseEntityTime(firstNonEmpty(head.Start.value, head.Date.value))
|
||||
if !ok {
|
||||
res.SkippedEntityNoTime++
|
||||
continue
|
||||
@@ -68,17 +141,17 @@ func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
|
||||
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
|
||||
// Истина при этом остаётся в Raw дословно.
|
||||
end := start
|
||||
if head.End != "" {
|
||||
if e, ok := parseEntityTime(head.End); ok {
|
||||
if head.End.value != "" {
|
||||
if e, ok := parseEntityTime(head.End.value); ok {
|
||||
end = e
|
||||
}
|
||||
}
|
||||
|
||||
_, offset := start.Zone()
|
||||
e := Entity{
|
||||
ID: head.ID,
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Name: head.Name,
|
||||
Name: head.Name.value,
|
||||
Start: start.UTC(),
|
||||
End: end.UTC(),
|
||||
OffsetSeconds: offset,
|
||||
@@ -136,6 +209,13 @@ func parseDuration(raw json.RawMessage) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// isJSONObject говорит, является ли значение объектом JSON, по первому байту
|
||||
// литерала — так же, как это делает сканер encoding/json.
|
||||
func isJSONObject(raw json.RawMessage) bool {
|
||||
lit := bytes.TrimSpace(raw)
|
||||
return len(lit) > 0 && lit[0] == '{'
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
|
||||
+109
-7
@@ -149,14 +149,14 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// Пустой id, отсутствующий id и id длиннее предела — один счётчик на три
|
||||
// случая: исход у них общий.
|
||||
if res.SkippedNoID != 3 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID)
|
||||
// Пустой id, отсутствующий id, id длиннее предела и id не строкой — один
|
||||
// счётчик на четыре случая: исход у них общий, сущность не адресуема.
|
||||
if res.SkippedNoID != 4 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 4", res.SkippedNoID)
|
||||
}
|
||||
// Метка не разбирается и метки нет вовсе.
|
||||
if res.SkippedEntityNoTime != 2 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime)
|
||||
// Метка не разбирается, метки нет вовсе и начало приехало не строкой.
|
||||
if res.SkippedEntityNoTime != 3 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 3", res.SkippedEntityNoTime)
|
||||
}
|
||||
// Элемент, не являющийся объектом.
|
||||
if res.SkippedEntityMalformed != 1 {
|
||||
@@ -176,6 +176,50 @@ func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Значение не того ТИПА стоит одного поля, а не сущности: иначе `name`,
|
||||
// приехавшее числом, уносит тренировку вместе с маршрутом, а доставка при
|
||||
// этом числится разобранной.
|
||||
t.Run("имя числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000c"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с именем-числом потерялась целиком")
|
||||
}
|
||||
if w.Name != "" {
|
||||
t.Errorf("имя %q, ожидалось пустое", w.Name)
|
||||
}
|
||||
if !strings.Contains(string(w.Raw), `"lat"`) {
|
||||
t.Error("маршрут не сохранился дословно")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("конец числом не уносит тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-00000000000d"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с концом-числом потерялась целиком")
|
||||
}
|
||||
if !w.End.Equal(w.Start) {
|
||||
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
|
||||
}
|
||||
})
|
||||
|
||||
// Фолбэк `start → date` существует для сущностей, у которых `start` не
|
||||
// прислан ВОВСЕ. Непонятое значение `start` фолбэка не получает: подстановка
|
||||
// другого поля дала бы метку другого момента времени, неотличимую от
|
||||
// настоящей и ничем не считаемую.
|
||||
t.Run("начало числом не подменяется полем date", func(t *testing.T) {
|
||||
if _, ok := byID["00000000-0000-4000-8000-00000000000e"]; ok {
|
||||
t.Error("нестроковое начало молча заменено меткой из date")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("идентификатор числом пропускает сущность", func(t *testing.T) {
|
||||
for id := range byID {
|
||||
if id == "42" {
|
||||
t.Error("нестроковый идентификатор приведён к строке — идентичность выдумана за источник")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000004"]
|
||||
if w.Duration != nil {
|
||||
@@ -316,3 +360,61 @@ func TestParseНепокрытыеСекцииССобственнымиID(t *te
|
||||
t.Errorf("непокрытые %v", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Элемент, который сам не объект, обязан идти в СВОЙ счётчик: сменившаяся форма
|
||||
// секции и сменившаяся форма идентификатора лечатся по-разному. `null` при этом
|
||||
// самый коварный — `json.Unmarshal` считает его законным no-op и не ошибается.
|
||||
func TestParseЭлементНеОбъектИдётВСвойСчётчик(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse([]byte(`{"data":{"workouts":[null,"строка",42,[1,2]]}}`), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if res.SkippedEntityMalformed != 4 {
|
||||
t.Errorf("не разобралось как объект %d, ожидалось 4", res.SkippedEntityMalformed)
|
||||
}
|
||||
if res.SkippedNoID != 0 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 0: форма секции — не форма id",
|
||||
res.SkippedNoID)
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор ключа JSON допускает, и весь разбор проекта пользуется семантикой
|
||||
// «побеждает последнее». Мягкое чтение обязано ей следовать: признак, копящийся
|
||||
// между вызовами, сделал бы заголовок функцией истории вызовов, а не тела.
|
||||
func TestParseПовторКлючаМеткиРешаетсяПоследнимЗначением(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","start":123,"start":"2025-06-05 07:00:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 1 {
|
||||
t.Fatalf("тренировок %d, ожидалась 1: валидная метка стоит последней", len(res.Workouts))
|
||||
}
|
||||
if res.Workouts[0].Start.IsZero() {
|
||||
t.Error("метка не разобралась")
|
||||
}
|
||||
}
|
||||
|
||||
// `"start": null` — это ключ, который пришёл. Фолбэк на `date` для него не
|
||||
// срабатывает: подстановка дала бы метку ДРУГОГО момента времени, неотличимую
|
||||
// от настоящей и ничем не считаемую.
|
||||
func TestParseПустойStartНеПодменяетсяПолемDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := `{"data":{"workouts":[{"id":"w1","date":"2025-06-01 00:00:00 +0300",` +
|
||||
`"start":null,"end":"2025-06-05 07:10:00 +0300"}]}}`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 0 {
|
||||
t.Errorf("тренировка получила метку %v из чужого поля", res.Workouts[0].Start)
|
||||
}
|
||||
if res.SkippedEntityNoTime != 1 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 1", res.SkippedEntityNoTime)
|
||||
}
|
||||
}
|
||||
|
||||
+77
-6
@@ -220,7 +220,7 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
res = Result{}
|
||||
err = fmt.Errorf("%w: паника разбора: %v", ErrMalformed, r)
|
||||
err = fmt.Errorf("%w: паника разбора: %s", ErrMalformed, clip(fmt.Sprint(r)))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -422,7 +422,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
|
||||
var env envelope
|
||||
|
||||
fail := func(e error) (envelope, error) {
|
||||
return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
|
||||
return envelope{}, fmt.Errorf("%w: %s", ErrMalformed, ClipCause(e))
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
@@ -430,6 +430,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
|
||||
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
|
||||
// список не идут — иначе в одном списке смешались бы имена секций и мусор
|
||||
// конверта, а форму `{"data": …}` проверяет приём.
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
@@ -441,7 +442,7 @@ func decodeEnvelope(body []byte) (envelope, error) {
|
||||
return envelope{}, nil
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %s", tokenDesc(tok, at)))
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
for dec.More() {
|
||||
@@ -486,13 +487,14 @@ type envelope struct {
|
||||
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
|
||||
// имена непокрытых.
|
||||
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
||||
return fmt.Errorf("data: ожидался объект, встречено %s", tokenDesc(tok, at))
|
||||
}
|
||||
|
||||
for dec.More() {
|
||||
@@ -544,17 +546,85 @@ func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
|
||||
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
|
||||
// escape-последовательностей, поэтому границы считаются по декодированному.
|
||||
func memberName(dec *json.Decoder) (string, error) {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name, ok := tok.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok)
|
||||
return "", fmt.Errorf("ожидалось имя члена, встречено %s", tokenDesc(tok, at))
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// maxCauseLen — предел длины чужой причины в тексте нашей ошибки.
|
||||
//
|
||||
// Сообщения самого разбора значений не несут (см. tokenDesc), но ошибка может
|
||||
// прийти и из encoding/json, а его UnmarshalTypeError кладёт в текст ЛИТЕРАЛ
|
||||
// значения: тело из миллиона цифр давало текст ошибки в мегабайт, и он уезжал
|
||||
// атрибутом `error` выше DEBUG. Предел держится здесь, на границе, а не у
|
||||
// логирующего: обрезка живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
const maxCauseLen = 200
|
||||
|
||||
// ClipCause переводит чужую ошибку в ограниченную по длине строку.
|
||||
//
|
||||
// Экспортировано ради приёма: он проверяет форму конверта тем же
|
||||
// encoding/json и обязан держать тот же предел — иначе инвариант обходится
|
||||
// через соседний пакет.
|
||||
func ClipCause(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return clip(err.Error())
|
||||
}
|
||||
|
||||
func clip(s string) string {
|
||||
if len(s) <= maxCauseLen {
|
||||
return s
|
||||
}
|
||||
// По границе рун: обрезка посреди многобайтовой руны даёт мусор в логе.
|
||||
cut := maxCauseLen
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "…"
|
||||
}
|
||||
|
||||
// tokenDesc описывает встреченный токен БЕЗ его значения: род и смещение начала
|
||||
// во входе.
|
||||
//
|
||||
// Значение из тела в сообщение не попадает никогда. Инвариант «тела запросов
|
||||
// только на DEBUG и с обрезкой» обходится одним `%v`: строка в 8 МиБ на месте
|
||||
// ожидаемого объекта давала текст ошибки в 8 МиБ, и он уезжал атрибутом `error`
|
||||
// на уровень WARN — то есть содержимое доставки оказывалось в логе целиком.
|
||||
// Предел держит само сообщение, а не обрезка на стороне логирующего: обрезка
|
||||
// живёт в другом месте и о новой ошибке разбора не узнает.
|
||||
//
|
||||
// Род называется словарём JSON, а не именем типа языка: `json.Delim` не говорит
|
||||
// ничего о том, какая скобка встретилась. Сам делимитер печатается значением —
|
||||
// он из фиксированного набора и содержимого не раскрывает.
|
||||
//
|
||||
// Смещение берётся ДО чтения токена: InputOffset() отдаёт позицию конца
|
||||
// последнего возвращённого токена, и снятое после оно указывало бы на конец
|
||||
// виновного значения — то есть на восемь мегабайт дальше начала проблемы.
|
||||
func tokenDesc(tok json.Token, at int64) string {
|
||||
kind := "?"
|
||||
switch v := tok.(type) {
|
||||
case json.Delim:
|
||||
kind = fmt.Sprintf("%q", string(v))
|
||||
case string:
|
||||
kind = "string"
|
||||
case json.Number, float64:
|
||||
kind = "number"
|
||||
case bool:
|
||||
kind = "bool"
|
||||
case nil:
|
||||
kind = "null"
|
||||
}
|
||||
return fmt.Sprintf("%s на смещении %d", kind, at)
|
||||
}
|
||||
|
||||
// swallow проглатывает значение целиком, ничего не удерживая.
|
||||
func swallow(dec *json.Decoder) error {
|
||||
var skip json.RawMessage
|
||||
@@ -562,12 +632,13 @@ func swallow(dec *json.Decoder) error {
|
||||
}
|
||||
|
||||
func expectDelim(dec *json.Decoder, want json.Delim) error {
|
||||
at := dec.InputOffset()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != want {
|
||||
return fmt.Errorf("ожидалось %q, встречено %v", want, tok)
|
||||
return fmt.Errorf("ожидалось %q, встречено %s", want, tokenDesc(tok, at))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -702,3 +702,87 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Инвариант «тела запросов только на 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()))
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -74,6 +74,30 @@
|
||||
"end": "2025-06-05 16:01:00 +0300"
|
||||
},
|
||||
"не объект вовсе",
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000c",
|
||||
"name": 5,
|
||||
"start": "2025-06-05 18:00:00 +0300",
|
||||
"end": "2025-06-05 18:10:00 +0300",
|
||||
"route": [{"lat": 1}]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000d",
|
||||
"name": "Конец числом",
|
||||
"start": "2025-06-05 19:00:00 +0300",
|
||||
"end": 0
|
||||
},
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Идентификатор числом",
|
||||
"start": "2025-06-05 20:00:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000e",
|
||||
"name": "Начало числом при живом date",
|
||||
"start": 1749100000,
|
||||
"date": "2025-06-05 22:00:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000009",
|
||||
"name": "Незнакомое поле и дословные литералы",
|
||||
|
||||
Reference in New Issue
Block a user