package store_test import ( "context" "encoding/json" "errors" "fmt" "strings" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/store" ) func fingerprint(t *testing.T, st *store.Store) string { t.Helper() fp, err := st.Fingerprint(context.Background()) if err != nil { t.Fatalf("отпечаток: %v", err) } return fp } // workout собирает тренировку с заданным содержимым. Заголовок в этих тестах // вторичен: правило замены смотрит на содержание, а не на колонки. func workout(t *testing.T, id, raw string) store.IncomingEntity { t.Helper() return store.IncomingEntity{ ID: id, Kind: "workouts", Name: "На улице Ходьба", Start: ts(t, "2025-06-05T07:00:00Z"), End: ts(t, "2025-06-05T07:10:00Z"), OffsetSeconds: 3 * 3600, Raw: json.RawMessage(raw), } } func from(t *testing.T, id, receivedAt string) store.DeliveryRef { t.Helper() return store.DeliveryRef{ID: id, ReceivedAt: ts(t, receivedAt)} } func mergeWorkouts(t *testing.T, st *store.Store, d store.DeliveryRef, ws ...store.IncomingEntity) store.MergeStats { t.Helper() stats, err := st.Merge(context.Background(), store.Incoming{Workouts: ws}, d) if err != nil { t.Fatalf("слияние сущностей: %v", err) } return stats } const ( // Содержимое подобрано так, чтобы отличаться от прежнего ЗНАЧЕНИЯМИ общих // полей: именно так тренировка и меняется между версиями (досчёт энергии), // и именно на этом ломается правило полноты, написанное для точек. woWithRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}], "activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}` woNoRouteNewValues = `{"id":"w1","name":"На улице Ходьба", "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}` woShortRoute = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1}], "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}` woRicher = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}], "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"stepCount":{"qty":900}}` woSameShapeNewValues = `{"id":"w1","name":"На улице Ходьба","route":[{"lat":1},{"lat":2},{"lat":3}], "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"}}` woIncomparable = `{"id":"w1","name":"На улице Ходьба", "activeEnergy":[{"qty":11}],"totalEnergy":{"qty":21,"units":"kJ"},"flightsClimbed":{"qty":3}}` ) func storedRaw(t *testing.T, st *store.Store, id string) string { t.Helper() w, err := st.Workout(context.Background(), id) if err != nil { t.Fatalf("чтение тренировки: %v", err) } return string(w.Raw) } // Единственная причина повторной присылки тренировки — доезжающий маршрут. func TestMergeДоехавшийМаршрутЗамещаетТренировку(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woNoRouteNewValues)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute)) if stats.WorkoutsWritten != 1 { t.Errorf("записано %d, ожидалась 1: версия с маршрутом полнее", stats.WorkoutsWritten) } if stats.EntitiesHeld != 0 { t.Errorf("удержано %d, ожидалось 0", stats.EntitiesHeld) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("в витрине не версия с маршрутом") } } // Тренировка досчитывается задним числом ровно так же, как минутное ведро: // набор полей тот же, значения новые. Тай-брейк по канонической форме // заморозил бы её на произвольной версии навсегда. func TestMergeДосчётПриТомЖеНабореПолейПобеждает(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues)) if stats.WorkoutsWritten != 1 { t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten) } if got := storedRaw(t, st, "w1"); got != woSameShapeNewValues { t.Error("досчитанная версия не легла в витрину") } } // Исход обязан быть функцией ЖУРНАЛА, а не порядка свёртки: воркер сворачивает // в порядке журнала только среди видимых ему доставок, и доставка с более // ранней меткой может свернуться позже. func TestMergeВерсияИзБолееРаннейДоставкиНеОткатываетВитрину(t *testing.T) { t.Parallel() early := from(t, "d1", "2025-06-05T08:00:00Z") late := from(t, "d2", "2025-06-05T08:05:00Z") прямой := open(t) mergeWorkouts(t, прямой, early, workout(t, "w1", woWithRoute)) mergeWorkouts(t, прямой, late, workout(t, "w1", woSameShapeNewValues)) обратный := open(t) mergeWorkouts(t, обратный, late, workout(t, "w1", woSameShapeNewValues)) mergeWorkouts(t, обратный, early, workout(t, "w1", woWithRoute)) a, b := storedRaw(t, прямой, "w1"), storedRaw(t, обратный, "w1") if a != b { t.Error("исход зависит от порядка свёртки — живая витрина разойдётся с пересборкой") } if a != woSameShapeNewValues { t.Error("победила версия не из более поздней доставки журнала") } } // Маршрут — 95% содержимого тренировки, а восстановление требует пересборки // всего журнала. Событие делается наблюдаемым, а не необратимым. func TestMergeОбеднённаяВерсияНеЗатираетСохранённую(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woNoRouteNewValues)) if stats.WorkoutsWritten != 0 { t.Errorf("записано %d, ожидалось 0: приехавшая теряет маршрут", stats.WorkoutsWritten) } if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалась 1 — событие обязано быть видно", stats.EntitiesHeld) } if len(stats.HeldAt) != 1 || stats.HeldAt[0].ID != "w1" { t.Errorf("координаты удержанной версии %v", stats.HeldAt) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("маршрут пропал из витрины") } } // Усечённый маршрут ключа не теряет — множеств ключей мало. func TestMergeУсечённыйМаршрутНеЗатираетСохранённый(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woShortRoute)) if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("полный маршрут вытеснен усечённым") } } // Несравнимые наборы: у каждой версии есть содержание, которого нет у другой. // Поля не объединяются, вместо этого — счётчик. func TestMergeНесравнимыеНаборыНеОбъединяются(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woIncomparable)) if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалась 1", stats.EntitiesHeld) } if got := storedRaw(t, st, "w1"); got != woWithRoute { t.Error("несравнимая версия заместила сохранённую") } } // Хеш — детектор изменений: тренировка переприсылается каждой доставкой, пока // не доедет маршрут, и 41 копия из 44 записи вызывать не должна. func TestMergeПовторТойЖеТренировкиНеПишет(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) // Тот же смысл, другой порядок ключей и дребезг литерала: каноническая // форма обязана совпасть. same := `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0}, "activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}` stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", same)) if stats.WorkoutsWritten != 0 { t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten) } } // Версии в разных порядках подачи: пункты правила, не зависящие от порядка // свёртки, обязаны давать одно состояние. Конвенция требует перестановки трёх, // а не пары (попарная свёртка уже давала нетранзитивную победу на точках) и // версии с содержимым, равным одной из присланных, — иначе ветка «содержание // равно» не посещается ни разу. func TestMergeПерестановкаВерсийДаётОдноСостояние(t *testing.T) { t.Parallel() type step struct { d store.DeliveryRef raw string } // Четвёртая версия несёт содержимое, РАВНОЕ одной из уже присланных. Без неё // перебор троек с разными хешами ветку «содержание равно» не посещает ни // разу — а именно на ней провенанс устаревал, и живая витрина расходилась с // пересборкой молча. steps := []step{ {from(t, "d1", "2025-06-05T08:00:00Z"), woNoRouteNewValues}, {from(t, "d2", "2025-06-05T08:05:00Z"), woWithRoute}, {from(t, "d3", "2025-06-05T08:10:00Z"), woRicher}, {from(t, "d4", "2025-06-05T08:15:00Z"), woWithRoute}, } orders := [][]int{ {0, 1, 2, 3}, {3, 2, 1, 0}, {1, 0, 3, 2}, {2, 3, 0, 1}, {1, 3, 0, 2}, {3, 0, 2, 1}, } var want string for i, order := range orders { st := open(t) for _, idx := range order { mergeWorkouts(t, st, steps[idx].d, workout(t, "w1", steps[idx].raw)) } got := storedRaw(t, st, "w1") if i == 0 { want = got if want != woRicher { t.Fatalf("победила не самая полная версия") } continue } if got != want { t.Errorf("порядок %v дал другое состояние", order) } } } // Порядок элементов в JSON-массиве нестабилен, поэтому две версии одного ключа // внутри одной доставки не имеют права разрешаться «последним в массиве». func TestMergeДвеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) { t.Parallel() d := from(t, "d1", "2025-06-05T08:00:00Z") прямой := open(t) mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameShapeNewValues)) обратный := open(t) mergeWorkouts(t, обратный, d, workout(t, "w1", woSameShapeNewValues), workout(t, "w1", woWithRoute)) if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") { t.Error("исход зависит от порядка элементов в массиве секции") } } // Записи разных родов с одним идентификатором — разные записи: ключ пара, а не // один id. func TestMergeЗаписиРазныхРодовСОднимIDНеСталкиваются(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() mind := store.IncomingEntity{ ID: "e1", Kind: "stateOfMind", Start: ts(t, "2025-06-05T18:00:00Z"), End: ts(t, "2025-06-05T18:00:00Z"), Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`), } ecg := store.IncomingEntity{ ID: "e1", Kind: "ecg", Start: ts(t, "2025-06-05T19:00:00Z"), End: ts(t, "2025-06-05T19:00:00Z"), Raw: json.RawMessage(`{"id":"e1","classification":"sinusRhythm"}`), } if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{mind, ecg}}, from(t, "d1", "2025-06-05T20:00:00Z")); err != nil { t.Fatalf("слияние записей: %v", err) } for _, kind := range []string{"stateOfMind", "ecg"} { if _, err := st.Record(ctx, kind, "e1"); err != nil { t.Errorf("запись рода %q не найдена: %v", kind, err) } } } // Отпечаток — единственный оракул сходимости. Витрины, совпадающие по часовым // объектам, но разошедшиеся в тренировке, обязаны давать разные отпечатки. func TestFingerprintРазличаетТренировки(t *testing.T) { t.Parallel() ctx := context.Background() build := func(raw string) string { st := open(t) in := store.Incoming{ Points: []store.IncomingPoint{point(t, "step_count", "minute", "2025-06-05T10:00:00Z", "2025-06-05T10:00:00Z", `{"qty":1}`)}, Workouts: []store.IncomingEntity{workout(t, "w1", raw)}, } if _, err := st.Merge(ctx, in, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } fp, err := st.Fingerprint(ctx) if err != nil { t.Fatalf("отпечаток: %v", err) } return fp } full, changed := build(woWithRoute), build(woSameShapeNewValues) if full == changed { t.Error("отпечатки совпали при разошедшемся содержимом тренировки") } if again := build(woWithRoute); again != full { t.Error("отпечаток не воспроизводится на одном содержимом") } } // Длительность: ноль — законное измерение, а «не прислали» обязано быть // отличимо от него. func TestWorkoutДлительностьОтличаетНольОтОтсутствия(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() zero := 0.0 withZero := workout(t, "w-zero", `{"id":"w-zero","qty":1}`) withZero.Duration = &zero withNone := workout(t, "w-none", `{"id":"w-none","qty":1}`) if _, err := st.Merge(ctx, store.Incoming{Workouts: []store.IncomingEntity{withZero, withNone}}, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } w, err := st.Workout(ctx, "w-zero") if err != nil { t.Fatalf("чтение: %v", err) } if w.Duration == nil || *w.Duration != 0 { t.Errorf("нулевая длительность потерялась: %v", w.Duration) } w, err = st.Workout(ctx, "w-none") if err != nil { t.Fatalf("чтение: %v", err) } if w.Duration != nil { t.Errorf("отсутствие длительности стало значением %v", *w.Duration) } } // Содержимое сущности хранится дословно: побайтовый круг «запись → чтение». func TestWorkoutСодержимоеХранитсяДословно(t *testing.T) { t.Parallel() st := open(t) // Литералы, которые теряет любая пересборка через разобранные значения: // `1.0`, целое больше 2^53, дробь длиннее двенадцати значащих цифр, а также // символы, которые json.Marshal экранирует по умолчанию. const raw = `{"id":"w9","route":[{"lat":1.0,"big":9007199254740993,"p":0.123456789012345678}], "note":"ad","isIndoor":false}` if _, err := st.Merge(context.Background(), store.Incoming{Workouts: []store.IncomingEntity{workout(t, "w9", raw)}}, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } if got := storedRaw(t, st, "w9"); got != raw { t.Errorf("содержимое изменилось при хранении:\nбыло %s\nстало %s", raw, got) } } // Провенанс нужен не отчётности: по нему разрешается тай-брейк, и без него // запись WARN об удержанной версии не связать с телом в архиве. func TestWorkoutНесётПровенанс(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d7", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) w, err := st.Workout(context.Background(), "w1") if err != nil { t.Fatalf("чтение: %v", err) } if w.Delivery != "d7" { t.Errorf("провенанс %q, ожидался d7", w.Delivery) } if w.Start.IsZero() || w.End.Before(w.Start) { t.Errorf("интервал заголовка неверен: %v — %v", w.Start, w.End) } if w.OffsetSeconds != 3*3600 { t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds) } } // Внутри одной доставки «сохранённой» версии не существует — есть только // порядок элементов в JSON-массиве, а он нестабилен. Ни исход, ни счётчик не // имеют права от него зависеть. func TestMergeНесравнимыеВерсииВОдномТелеНеЗависятОтПорядка(t *testing.T) { t.Parallel() d := from(t, "d1", "2025-06-05T08:00:00Z") const withRoute = `{"id":"w1","route":[{"lat":1}],"stepCount":{"qty":9}}` const withFlights = `{"id":"w1","route":[{"lat":1}],"flightsClimbed":{"qty":3}}` прямой := open(t) a := mergeWorkouts(t, прямой, d, workout(t, "w1", withRoute), workout(t, "w1", withFlights)) обратный := open(t) b := mergeWorkouts(t, обратный, d, workout(t, "w1", withFlights), workout(t, "w1", withRoute)) if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") { t.Error("исход зависит от порядка элементов в массиве секции") } if a.EntitiesDiverging != b.EntitiesDiverging { t.Errorf("счётчик зависит от порядка: %d против %d", a.EntitiesDiverging, b.EntitiesDiverging) } if a.EntitiesDiverging == 0 { t.Error("две версии с разным содержанием в одном теле остались незамеченными") } // Удержаний тут нет: победитель лёг в витрину целиком, терять нечего. // Счётчики разведены именно ради этого различия. if a.EntitiesHeld != 0 || b.EntitiesHeld != 0 { t.Errorf("несравнимые версии в одном теле посчитаны удержаниями: %d и %d", a.EntitiesHeld, b.EntitiesHeld) } } // Отпечаток обязан различать состояния, а не только содержимое: составной ключ // записи, склеенный до взятия длины, даёт (`a`, `b/c`) = (`a/b`, `c`). func TestFingerprintРазличаетСоставнойКлючЗаписи(t *testing.T) { t.Parallel() ctx := context.Background() build := func(kind, id string) string { st := open(t) rec := store.IncomingEntity{ ID: id, Kind: kind, Start: ts(t, "2025-06-05T18:00:00Z"), End: ts(t, "2025-06-05T18:00:00Z"), Raw: json.RawMessage(`{"id":"x","valence":0.5}`), } if _, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec}}, from(t, "d1", "2025-06-05T08:00:00Z")); err != nil { t.Fatalf("слияние: %v", err) } fp, err := st.Fingerprint(ctx) if err != nil { t.Fatalf("отпечаток: %v", err) } return fp } if build("a", "b/c") == build("a/b", "c") { t.Error("два разных состояния витрины дали один отпечаток: составной ключ склеен до взятия длины") } } // Оракулы враждебного прохода ревью: тело контролирует отправитель целиком, и // «версия той же формы без содержания» проходила все проверки — а маршрут это // 95% тренировки, которого в экспорте Apple нет вовсе. Восстановить его после // затирания не может даже пересборка: журнал проиграет то же поражение. const ( woRealWorkout = `{"id":"w7","name":"В помещении Ходьба","isIndoor":true, "maxHeartRate":{"qty":199,"units":"count/min"}, "heartRate":{"max":{"qty":199},"avg":{"qty":47.2},"min":{"qty":41}}, "heartRateData":[{"Max":199,"Avg":86.1,"Min":41},{"Max":150,"Avg":80.0,"Min":44}], "activeEnergy":[{"qty":49.4,"units":"kJ"},{"qty":12.1,"units":"kJ"}], "totalEnergy":{"qty":66.4,"units":"kJ"},"duration":11.1}` woSkeleton = `{"id":"w7","name":"x","isIndoor":false, "maxHeartRate":1,"heartRate":1, "heartRateData":[null,null],"activeEnergy":[null,null], "totalEnergy":1,"duration":1}` woNulledRoute = `{"id":"w1","name":"На улице Ходьба","route":[null,null,null], "activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}` woEmptyRoute = `{"id":"w1","name":"На улице Ходьба","route":[{},{},{}], "activeEnergy":[{"qty":10}],"totalEnergy":{"qty":20,"units":"kJ"}}` // Тот же смысл, другой порядок ключей и другая запись числа: каноническая // форма совпадает, байты — нет. woSameFormOtherBytes = `{"name":"На улице Ходьба","id":"w1","totalEnergy":{"units":"kJ","qty":20.0}, "activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}` ) func TestMergeСкелетНеВытесняетТренировку(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w7", woRealWorkout)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w7", woSkeleton)) if got := storedRaw(t, st, "w7"); !strings.Contains(got, "199") { t.Error("тренировка заменена скелетом из скаляров и null") } if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалось 1: событие обязано быть видно", stats.EntitiesHeld) } } func TestMergeМаршрутНеЗатираетсяПустышкамиТойЖеДлины(t *testing.T) { t.Parallel() for name, poor := range map[string]string{"null": woNulledRoute, "пустые объекты": woEmptyRoute} { t.Run(name, func(t *testing.T) { t.Parallel() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", poor)) if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) { t.Error("маршрут затёрт рядом той же длины без содержания") } if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld) } }) } } // Ключ с пустым значением исчезал бы по жребию тай-брейка. Второй разряд // сравнения записан для точек и здесь применяется к сущностям. func TestMergeКлючСПустымЗначениемНеИсчезает(t *testing.T) { t.Parallel() const ( withEmpty = `{"id":"w1","qty":10,"context":null}` noEmpty = `{"id":"w1","qty":11}` ) st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", withEmpty)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", noEmpty)) if got := storedRaw(t, st, "w1"); !strings.Contains(got, "context") { t.Error("ключ с пустым значением исчез по жребию") } if stats.EntitiesHeld != 1 { t.Errorf("удержано %d, ожидалось 1", stats.EntitiesHeld) } } // Две версии одного `id` в ОДНОМ теле с разным содержанием: счётчик не имеет // права молчать. До правки он давал ноль — то есть событие проходило бы как // штатное INFO. func TestMergeДвеВерсииВОдномТелеСчитаются(t *testing.T) { t.Parallel() d := from(t, "d1", "2025-06-05T08:00:00Z") st := open(t) stats := mergeWorkouts(t, st, d, workout(t, "w1", woWithRoute), workout(t, "w1", woNulledRoute)) if stats.EntitiesDiverging == 0 { t.Error("две версии одного id в одном теле — счётчик молчит") } if got := storedRaw(t, st, "w1"); !strings.Contains(got, `"lat":1`) { t.Error("в теле победила версия без содержания") } } // Побайтовое различие при совпавшей канонической форме событием не является: // порядок ключей у HAE нестабилен и дребезг последнего разряда тоже. Но байты в // витрине обязаны быть одни при любой перестановке — иначе исход зависит от // порядка элементов на проводе. func TestMergeРавнаяФормаРазныеБайтыНеСобытие(t *testing.T) { t.Parallel() d := from(t, "d1", "2025-06-05T08:00:00Z") прямой := open(t) s1 := mergeWorkouts(t, прямой, d, workout(t, "w1", woWithRoute), workout(t, "w1", woSameFormOtherBytes)) обратный := open(t) s2 := mergeWorkouts(t, обратный, d, workout(t, "w1", woSameFormOtherBytes), workout(t, "w1", woWithRoute)) if s1.EntitiesDiverging != 0 || s2.EntitiesDiverging != 0 { t.Errorf("счётчик сработал на дребезге записи: %d и %d", s1.EntitiesDiverging, s2.EntitiesDiverging) } if storedRaw(t, прямой, "w1") != storedRaw(t, обратный, "w1") { t.Error("в витрине разные байты при одинаковом содержимом: победитель зависит от порядка") } } // Победитель внутри доставки — функция МНОЖЕСТВА версий. Попарная свёртка // частичного порядка с тотальным тай-брейком нетранзитивна: [A,B,C] давало C, // [B,C,A] давало A. func TestMergeПерестановкаТрёхВерсийВОдномТеле(t *testing.T) { t.Parallel() const ( vA = `{"a":[9,9],"b":2,"id":"k"}` vB = `{"a":[1,2],"id":"k"}` vC = `{"a":[1],"c":3,"id":"k"}` ) orders := [][]string{ {vA, vB, vC}, {vA, vC, vB}, {vB, vA, vC}, {vB, vC, vA}, {vC, vA, vB}, {vC, vB, vA}, } var want string for i, order := range orders { st := open(t) ws := make([]store.IncomingEntity, 0, len(order)) for _, raw := range order { ws = append(ws, workout(t, "k", raw)) } mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...) got := storedRaw(t, st, "k") if i == 0 { want = got continue } if got != want { t.Errorf("порядок %d дал другого победителя:\n %s\n %s", i, got, want) } } } // Провенанс обязан отражать победителя ЖУРНАЛА, а не первую свёрнутую копию. // Иначе доставка, свёрнутая с опозданием, вернёт витрину к прежнему содержимому, // и живая витрина разойдётся с пересборкой молча — в содержимом тренировки. func TestMergeОтложеннаяДоставкаНеВозвращаетПрежнееСодержимое(t *testing.T) { t.Parallel() journal := open(t) mergeWorkouts(t, journal, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) mergeWorkouts(t, journal, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues)) mergeWorkouts(t, journal, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute)) deferred := open(t) mergeWorkouts(t, deferred, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) mergeWorkouts(t, deferred, from(t, "d2", "2025-06-05T08:10:00Z"), workout(t, "w1", woWithRoute)) mergeWorkouts(t, deferred, from(t, "d3", "2025-06-05T08:05:00Z"), workout(t, "w1", woSameShapeNewValues)) if a, b := storedRaw(t, journal, "w1"), storedRaw(t, deferred, "w1"); a != b { t.Errorf("состояние зависит от порядка свёртки:\n %s\n %s", a, b) } if a, b := fingerprint(t, journal), fingerprint(t, deferred); a != b { t.Errorf("отпечатки разошлись: %s против %s", a, b) } } // Провенанс поднимается до более поздней доставки журнала даже при совпавшем // хеше — иначе тай-брейк «содержание равно» решает по устаревшей позиции. func TestMergeПовторОбновляетПровенанс(t *testing.T) { t.Parallel() ctx := context.Background() st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w1", woWithRoute)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w1", woWithRoute)) if stats.WorkoutsWritten != 0 { t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.WorkoutsWritten) } w, err := st.Workout(ctx, "w1") if err != nil { t.Fatalf("чтение тренировки: %v", err) } if w.Delivery != "d2" { t.Errorf("провенанс %q, ожидался d2: победитель журнала — более поздняя доставка", w.Delivery) } } // Провенанс записи обновляется тем же путём, что и у тренировки, но ключ у неё // СОСТАВНОЙ — а значит текст `WHERE` и хвост аргументов обязаны совпадать. Ветка // не покрывалась ни одним тестом, притом что промах давал бы `UPDATE` в ноль // строк, невидимый ни в отпечатке (провенанса там нет), ни в счётчиках. // Род `record` — единственный, где цена необратима: в экспорте Apple его нет. func TestMergeПровенансЗаписиОбновляетсяПоСоставномуКлючу(t *testing.T) { t.Parallel() ctx := context.Background() st := open(t) rec := func() store.IncomingEntity { return store.IncomingEntity{ ID: "e1", Kind: "stateOfMind", Start: ts(t, "2025-06-05T18:00:00Z"), End: ts(t, "2025-06-05T18:00:00Z"), Raw: json.RawMessage(`{"id":"e1","kind":"momentary_emotion","valence":0.5}`), } } merge := func(d store.DeliveryRef) store.MergeStats { t.Helper() s, err := st.Merge(ctx, store.Incoming{Records: []store.IncomingEntity{rec()}}, d) if err != nil { t.Fatalf("слияние записи: %v", err) } return s } merge(from(t, "d1", "2025-06-05T20:00:00Z")) stats := merge(from(t, "d2", "2025-06-05T20:05:00Z")) if stats.RecordsWritten != 0 { t.Errorf("записано %d, ожидалось 0: содержимое то же", stats.RecordsWritten) } got, err := st.Record(ctx, "stateOfMind", "e1") if err != nil { t.Fatalf("чтение записи: %v", err) } if got.Delivery != "d2" { t.Errorf("провенанс %q, ожидался d2", got.Delivery) } } // Число версий одного ключа приходит из чужого тела, а выбор победителя по ним // квадратичен. Отмена обязана прерывать отбор: без неё тело с двадцатью // тысячами версий занимает единственного воркера свёртки дольше, чем длится его // собственный дедлайн, и очередь встаёт молча при зелёном `/healthz`. func TestMergeОтменаПрерываетВыборПобедителя(t *testing.T) { t.Parallel() st := open(t) ctx, cancel := context.WithCancel(context.Background()) cancel() ws := make([]store.IncomingEntity, 0, 3) for i := range 3 { raw := fmt.Sprintf(`{"id":"w1","qty":%d,"route":[{"lat":%d}]}`, i, i) ws = append(ws, workout(t, "w1", raw)) } _, err := st.Merge(ctx, store.Incoming{Workouts: ws}, from(t, "d1", "2025-06-05T08:00:00Z")) if !errors.Is(err, context.Canceled) { t.Errorf("слияние дало %v, ожидалась отмена", err) } } // Версии с совпавшей канонической формой схлопываются ДО квадратичного отбора: // иначе тело, вмещающее сотни тысяч копий одного `id`, стоит часов работы. При // этом схлопывание не имеет права менять исход — победитель тот же. func TestMergeРавныеФормыСхлопываютсяДоОтбора(t *testing.T) { t.Parallel() st := open(t) ws := make([]store.IncomingEntity, 0, 2000) for range 2000 { ws = append(ws, workout(t, "w1", woWithRoute)) } ws = append(ws, workout(t, "w1", woRicher)) done := make(chan store.MergeStats, 1) go func() { done <- mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), ws...) }() select { case stats := <-done: if got := storedRaw(t, st, "w1"); got != woRicher { t.Errorf("схлопывание изменило победителя:\n%s", got) } // Победитель — единственная более полная версия; от неё формой // отличаются две тысячи копий победнее, и схлопывание их не прячет: // счётчик считает версии, а не группы. if stats.EntitiesDiverging != 2000 { t.Errorf("различающихся версий %d, ожидалось 2000", stats.EntitiesDiverging) } case <-time.After(20 * time.Second): t.Fatal("слияние не уложилось в 20 с: схлопывание не работает") } } // Регрессия, найденная враждебным проходом ревью: безусловный второй разряд // правила покрытия запирал законный досчёт НАВСЕГДА. Версия с пустым ключом и // без маршрута оказывалась несравнимой с версией, у которой маршрут приехал, а // этого ключа нет, — и маршрут не доезжал ни одной доставкой, причём пересборка // проигрывала то же поражение. Второй разряд разрешает спор равных, а не // отменяет первый. func TestMergeПустойКлючНеЗапираетДосчёт(t *testing.T) { t.Parallel() const ( сПустымКлючом = `{"id":"w2","activeEnergy":[{"qty":10}],"totalEnergy":null}` сМаршрутом = `{"id":"w2","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2},{"lat":3}]}` ) st := open(t) mergeWorkouts(t, st, from(t, "d1", "2025-06-05T08:00:00Z"), workout(t, "w2", сПустымКлючом)) stats := mergeWorkouts(t, st, from(t, "d2", "2025-06-05T08:05:00Z"), workout(t, "w2", сМаршрутом)) if got := storedRaw(t, st, "w2"); !strings.Contains(got, `"lat":1`) { t.Errorf("маршрут не доехал — досчёт заперт пустым ключом:\n%s", got) } if stats.WorkoutsWritten != 1 { t.Errorf("записано %d, ожидалась 1", stats.WorkoutsWritten) } if stats.EntitiesHeld != 0 { t.Errorf("удержано %d: законный досчёт принят за потерю содержания", stats.EntitiesHeld) } } // Единственная точка, где витрина НЕ является функцией множества доставок, — // несравнимые версии. Тест не чинит это, а закрепляет: в витрине лежит // победитель прошлых слияний, а не все кандидаты истории, поэтому «сохранённая // выигрывает» даёт разный итог при разном порядке. Порядок свёртки журналу не // равен: доставка, получившая ErrBusy, остаётся `pending` и сворачивается // следующим проходом. // // Предел назван в pickEntity и в architecture.md; наблюдается счётчиком // удержаний. Если он когда-нибудь будет закрыт, красный тест напомнит, что // текст в обоих местах пора переписать. func TestMergeНесравнимыеВерсииЗависятОтПорядкаСвёртки(t *testing.T) { t.Parallel() const ( база = `{"id":"w3","activeEnergy":[{"qty":10}]}` сТреком = `{"id":"w3","activeEnergy":[{"qty":10}],"route":[{"lat":1},{"lat":2}]}` сЭтажами = `{"id":"w3","activeEnergy":[{"qty":10}],"flightsClimbed":{"qty":3}}` ) d1 := from(t, "d1", "2025-06-05T08:00:00Z") d2 := from(t, "d2", "2025-06-05T08:05:00Z") d3 := from(t, "d3", "2025-06-05T08:10:00Z") журнальный := open(t) mergeWorkouts(t, журнальный, d1, workout(t, "w3", база)) mergeWorkouts(t, журнальный, d2, workout(t, "w3", сТреком)) mergeWorkouts(t, журнальный, d3, workout(t, "w3", сЭтажами)) отложенный := open(t) mergeWorkouts(t, отложенный, d1, workout(t, "w3", база)) mergeWorkouts(t, отложенный, d3, workout(t, "w3", сЭтажами)) stats := mergeWorkouts(t, отложенный, d2, workout(t, "w3", сТреком)) a, b := storedRaw(t, журнальный, "w3"), storedRaw(t, отложенный, "w3") if a == b { t.Fatal("предел закрылся — перепиши текст в pickEntity и architecture.md") } // И главное: расхождение не молчит. if stats.EntitiesHeld == 0 { t.Error("расхождение по порядку свёртки не отражено счётчиком удержаний") } }