package store_test import ( "context" "fmt" "sync" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/store" ) func category(metric, field, value, code string) store.CategoryValue { return store.CategoryValue{Metric: metric, Field: field, Value: value, Code: code} } func mergeCategories(t *testing.T, st *store.Store, d store.DeliveryRef, vs ...store.CategoryValue) { t.Helper() if _, err := st.Merge(context.Background(), store.Incoming{Categories: vs}, d); err != nil { t.Fatalf("слияние наблюдений: %v", err) } } func categories(t *testing.T, st *store.Store) []store.CategoryValue { t.Helper() got, err := st.CategoryValues(context.Background()) if err != nil { t.Fatalf("чтение реестра: %v", err) } return got } func TestMergeCategoriesКладётНаблюдение(t *testing.T) { t.Parallel() st := open(t) d := from(t, "01AAA", "2026-08-03T10:00:00Z") mergeCategories(t, st, d, category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified"), category("heart_rate", "context", "Не задано", "")) got := categories(t, st) if len(got) != 2 { t.Fatalf("строк реестра %d, ожидалось 2: %+v", len(got), got) } // Порядок детерминирован ключом. if got[0].Metric != "heart_rate" || got[1].Metric != "sleep_analysis" { t.Errorf("порядок реестра не по ключу: %+v", got) } // Пустой код — законное состояние: словарь этой строки не знает. if got[0].Code != "" { t.Errorf("контекст пульса получил код %q", got[0].Code) } if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" { t.Errorf("фаза сна получила код %q", got[1].Code) } if got[1].FirstDeliveryID != d.ID || !got[1].FirstSeen.Equal(d.ReceivedAt) { t.Errorf("провенанс %s/%s, ожидался %s/%s", got[1].FirstDeliveryID, got[1].FirstSeen, d.ID, d.ReceivedAt) } } // Повторная свёртка той же доставки не меняет ни одной колонки: иначе состояние // зависело бы от числа прогонов, а пересборка перестала бы быть no-op. func TestMergeCategoriesПовторНичегоНеМеняет(t *testing.T) { t.Parallel() st := open(t) d := from(t, "01AAA", "2026-08-03T10:00:00Z") v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified") mergeCategories(t, st, d, v) first := fingerprint(t, st) before := categories(t, st) mergeCategories(t, st, d, v) if got := fingerprint(t, st); got != first { t.Error("повторная свёртка сдвинула отпечаток") } after := categories(t, st) if len(after) != len(before) || after[0] != before[0] { t.Errorf("повтор изменил реестр: было %+v, стало %+v", before, after) } } // Провенанс — МИНИМУМ по журналу, а не последняя запись: иначе проигрывание // журнала давало бы не то, что живой приём, и порядок свёртки решал бы исход. // // Три доставки во всех шести порядках, а не пара: пара доказывает // коммутативность и молчит про ассоциативность. func TestMergeCategoriesПровенансНеЗависитОтПорядка(t *testing.T) { t.Parallel() deliveries := []store.DeliveryRef{ from(t, "01AAA", "2026-08-01T10:00:00Z"), from(t, "01BBB", "2026-08-02T10:00:00Z"), // Та же секунда, что у предыдущей: порядок задаёт идентификатор, и // сравнение по одной метке сделало бы исход зависящим от того, какая // свернулась раньше. from(t, "01AAB", "2026-08-02T10:00:00Z"), } v := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified") orders := [][]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} var want string for _, order := range orders { t.Run(fmt.Sprint(order), func(t *testing.T) { st := open(t) for _, i := range order { mergeCategories(t, st, deliveries[i], v) } got := categories(t, st) if len(got) != 1 { t.Fatalf("строк реестра %d, ожидалась одна", len(got)) } if got[0].FirstDeliveryID != "01AAA" { t.Errorf("провенанс %s, ожидалась самая ранняя доставка 01AAA", got[0].FirstDeliveryID) } // Отпечаток обязан совпасть у всех шести порядков. fp := fingerprint(t, st) if want == "" { want = fp } else if fp != want { t.Errorf("порядок %v дал другой отпечаток", order) } }) } } // Наблюдение в отпечаток входит, выведенный код — нет. Код производен от // словаря в бинаре, а не от журнала: включённый в отпечаток, он заставил бы // всякое пополнение словаря давать расхождение при совпавшем журнале. func TestFingerprintРеестрБезКода(t *testing.T) { t.Parallel() d := from(t, "01AAA", "2026-08-03T10:00:00Z") withCode := open(t) mergeCategories(t, withCode, d, category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified")) otherCode := open(t) mergeCategories(t, otherCode, d, category("sleep_analysis", "value", "Во сне", "")) if fingerprint(t, withCode) != fingerprint(t, otherCode) { t.Error("расхождение только по коду сдвинуло отпечаток — оракул сходимости стал функцией версии словаря") } otherValue := open(t) mergeCategories(t, otherValue, d, category("sleep_analysis", "value", "Бодрствование", "HKCategoryValueSleepAnalysisAwake")) if fingerprint(t, withCode) == fingerprint(t, otherValue) { t.Error("разошедшееся наблюдение отпечаток не сдвинуло") } } // Значение приходит из тела дословно и может содержать что угодно, включая // признак раздела и разделители полей. Длина впереди каждого поля — то, что не // даёт двум разным состояниям витрины сойтись отпечатком. func TestFingerprintРазделительВЗначенииГраницуНеПодделывает(t *testing.T) { t.Parallel() d := from(t, "01AAA", "2026-08-03T10:00:00Z") a := open(t) mergeCategories(t, a, d, category("sleep", "value|c|9", "x", "")) b := open(t) mergeCategories(t, b, d, category("sleep", "value", "|c|9|x", "")) if fingerprint(t, a) == fingerprint(t, b) { t.Error("разное разбиение тех же байтов по полям дало один отпечаток") } } // Пустой реестр отпечаток не ломает: раздел, у которого нет строк, не пишет в // хеш ничего. func TestFingerprintПустойРеестр(t *testing.T) { t.Parallel() empty := open(t) ctx := context.Background() fp, err := empty.Fingerprint(ctx) if err != nil { t.Fatalf("отпечаток пустой витрины: %v", err) } if fp == "" { t.Error("отпечаток пустой витрины пуст") } n, err := empty.CountCategoryValues(ctx) if err != nil { t.Fatalf("счёт реестра: %v", err) } if n != 0 { t.Errorf("строк реестра %d, ожидался ноль", n) } } // Отказ слияния не оставляет строк реестра: доставка — единица свёртки, и // частичное состояние повторная свёртка не чинит. func TestMergeCategoriesОтказНеОставляетСтрок(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() // Точка, содержимое которой не канонизируется, роняет всю транзакцию. in := store.Incoming{ Points: []store.IncomingPoint{point(t, "m", "hour", "2026-08-03T10:00:00Z", "2026-08-03T10:00:00Z", `{{{`)}, Categories: []store.CategoryValue{category("sleep_analysis", "value", "Во сне", "код")}, } if _, err := st.Merge(ctx, in, from(t, "01AAA", "2026-08-03T10:00:00Z")); err == nil { t.Skip("слияние не отказало — проверять нечего") } if got := categories(t, st); len(got) != 0 { t.Errorf("после отказа в реестре %d строк: %+v", len(got), got) } } // Доставки приходят внахлёст, а провенанс реестра выбирается правилом // «минимум по журналу» прямо в SQL. Правило, ни разу не исполненное // конкурентно, проверено ровно наполовину: у точек такой прогон есть с самого // начала (TestMergeКонкурентноеСлияниеНеТеряетТочки), у реестра его не было. // // Порядок горутин недетерминирован намеренно — в этом весь смысл: исход обязан // быть функцией МНОЖЕСТВА доставок, а не того, кто первым добрался до // транзакции. func TestMergeCategoriesКонкурентноНеПортитПровенанс(t *testing.T) { t.Parallel() st := open(t) ctx := context.Background() const writers = 8 const perWriter = 10 shared := category("sleep_analysis", "value", "Во сне", "HKCategoryValueSleepAnalysisAsleepUnspecified") var wg sync.WaitGroup errs := make(chan error, writers) for w := range writers { wg.Go(func() { for i := range perWriter { // Метка приёма растёт вместе с номером доставки: самая ранняя — // у первой итерации первого писателя, и она обязана победить // независимо от того, кто дошёл до базы раньше. n := w*perWriter + i d := store.DeliveryRef{ ID: fmt.Sprintf("d%03d", n), ReceivedAt: ts(t, "2026-08-03T10:00:00Z").Add(time.Duration(n) * time.Second), } // Общий ключ у всех писателей плюс свой собственный: проверяется // и спор за одну строку, и параллельная вставка разных. own := category("heart_rate", "context", fmt.Sprintf("контекст-%03d", n), "") if _, err := st.Merge(ctx, store.Incoming{Categories: []store.CategoryValue{shared, own}}, d); err != nil { errs <- err return } } }) } wg.Wait() close(errs) for err := range errs { t.Fatalf("конкурентное слияние реестра: %v", err) } got := categories(t, st) if len(got) != writers*perWriter+1 { t.Fatalf("строк реестра %d, ожидалось %d", len(got), writers*perWriter+1) } for _, v := range got { if v.Metric != "sleep_analysis" { continue } if v.FirstDeliveryID != "d000" { t.Errorf("провенанс общей строки %q, ожидалась самая ранняя доставка d000", v.FirstDeliveryID) } if !v.FirstSeen.Equal(ts(t, "2026-08-03T10:00:00Z")) { t.Errorf("метка первой встречи %s, ожидалась 2026-08-03T10:00:00Z", v.FirstSeen) } } }