package fold_test import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "path/filepath" "strings" "testing" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/fold" "git.vakhrushev.me/av/healthlog/internal/store" ) // Данные о здоровье чувствительнее токенов, и требование «значения точек не в // логах» до сих пор не проверялось ничем: все тестовые логгеры выбрасывали // записи. Тест ловит записи и смотрит на них. func TestFoldНеПишетЗначенийВЛог(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) f := fold.New(arch, st, 0, log) // Значения и имя устройства выбраны так, чтобы их нельзя было спутать ни с // чем: если они окажутся в логе, это будет видно. body := []byte(`{"data":{"metrics":[{"name":"step_count","units":"count","data":[` + `{"date":"2025-06-05 10:00:00 +0300","qty":424242.7,"source":"Секретные Часы Антона"},` + `{"date":"2025-06-05 10:01:00 +0300","qty":313131.9,"source":"Секретные Часы Антона"},` + `{"date":"2025-06-05 10:02:00 +0300","qty":151515.1,"source":"Секретные Часы Антона"}` + `]}]}}`) deliver(t, arch, st, "d1", "Minutes", "auto-1", body) if _, err := f.Fold(context.Background(), "d1"); err != nil { t.Fatalf("свёртка: %v", err) } logged := buf.String() if logged == "" { t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит") } for _, secret := range []string{"424242", "313131", "151515", "Секретные Часы"} { if strings.Contains(logged, secret) { t.Errorf("в логе оказалось %q:\n%s", secret, logged) } } // И одновременно — чекпоинт обязан нести счётчики, иначе он бесполезен. var rec map[string]any for line := range strings.SplitSeq(strings.TrimSpace(logged), "\n") { if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("строка лога не JSON: %v", err) } if rec["msg"] == "delivery folded" { break } } for _, attr := range []string{"delivery_id", "points", "stored", "buckets", "layer", "skipped"} { if _, ok := rec[attr]; !ok { t.Errorf("в записи нет атрибута %q: %v", attr, rec) } } } // Доставка, у которой отброшены ВСЕ точки, — это сломавшийся формат, а не // штатная работа. Без WARN смена формата метки выглядела бы как здоровый // поток: 200, parsed, INFO, points=0. func TestFoldВсеТочкиОтброшеныДаётWarn(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) f := fold.New(arch, st, 0, log) // Метки в формате, которого разбор не знает: HAE сменил формат. body := []byte(`{"data":{"metrics":[{"name":"step_count","units":"count","data":[` + `{"date":"2025-06-05T10:00:00Z","qty":1},{"date":"2025-06-05T10:01:00Z","qty":2}` + `]}]}}`) deliver(t, arch, st, "d1", "Minutes", "auto-1", body) if _, err := f.Fold(context.Background(), "d1"); err != nil { t.Fatalf("свёртка: %v", err) } if !strings.Contains(buf.String(), `"level":"WARN"`) { t.Errorf("доставка без единой сохранённой точки записана не как WARN:\n%s", buf.String()) } } // Несравнимые наборы полей — то самое событие, ради наблюдения за которым // объединение полей не реализовано вовсе. Если оно когда-нибудь наступит, его // обязано быть видно, а не додумано задним числом. func TestFoldНесравнимыеНаборыДаютWarn(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) f := fold.New(arch, st, 0, log) ctx := context.Background() const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` + `{"date":"2025-06-05 10:00:00 +0300",%s}` + `]}]}}` deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1`))) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("первая свёртка: %v", err) } buf.Reset() deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"mealTime":"До еды"`))) stats, err := f.Fold(ctx, "d2") if err != nil { t.Fatalf("вторая свёртка: %v", err) } if stats.Incomparable != 1 { t.Fatalf("несравнимых %d, ожидался 1: тест проверяет не то", stats.Incomparable) } var rec map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil { t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String()) } if rec["level"] != "WARN" { t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String()) } if rec["msg"] != "delivery folded, incomparable point fields" { t.Errorf("сообщение %v не называет событие", rec["msg"]) } if rec["incomparable"] != float64(1) { t.Errorf("счётчик несравнимых %v, ожидался 1", rec["incomparable"]) } // Координаты объекта — не значения: метрика, слой и час. at, _ := rec["incomparable_at"].(string) if !strings.Contains(at, "blood_glucose/minute@") { t.Errorf("координаты объекта в записи не те: %q", at) } // И ни одного значения точки: данные о здоровье чувствительнее токенов. // // Метка времени из проверки исключается, и это не поблажка: она содержит // доли секунды, поэтому подстрока вроде "5.1" находится в ней примерно раз // на сотню прогонов — тест краснел от хода часов, а не от утечки. Проверять // надо запись без служебного поля, которое значений нести не может. delete(rec, "time") clean, err := json.Marshal(rec) if err != nil { t.Fatalf("запись лога не сериализуется: %v", err) } for _, secret := range []string{"5.1", "До еды"} { if strings.Contains(string(clean), secret) { t.Errorf("в логе оказалось значение точки %q:\n%s", secret, clean) } } } // Имена непокрытых секций в логе нужны — по ним видно, что поток принёс новое. // Содержимого секций там быть не может: это данные о здоровье. И уровень от // самой частичности не растёт — `partial` установившееся состояние половины // потока, а постоянный WARN каждые пять минут обесценивает уровень. func TestFoldЧастичныйРазборВЛоге(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) f := fold.New(arch, st, 0, log) ctx := context.Background() // Содержимое непокрытой секции помечено так, чтобы его нельзя было спутать // ни с чем: если оно окажется в логе, тест это увидит. const body = `{"data":{ "metrics":[{"name":"step_count","units":"count","data":[ {"date":"2025-06-05 10:00:00 +0300","qty":1}, {"date":"2025-06-05 10:01:00 +0300","qty":2}, {"date":"2025-06-05 10:02:00 +0300","qty":3}, {"date":"2025-06-05 10:03:00 +0300","qty":4}, {"date":"2025-06-05 10:04:00 +0300","qty":5}, {"date":"2025-06-05 10:05:00 +0300","qty":6}, {"date":"2025-06-05 10:06:00 +0300","qty":7}, {"date":"2025-06-05 10:07:00 +0300","qty":8}, {"date":"2025-06-05 10:08:00 +0300","qty":9}, {"date":"2025-06-05 10:09:00 +0300","qty":10}]}], "ecg":[{"classification":"СЕКРЕТНЫЙ-РИТМ"}]}}` deliver(t, arch, st, "d1", "Minutes", "a1", []byte(body)) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("свёртка: %v", err) } out := buf.String() if !strings.Contains(out, "ecg") { t.Error("имени непокрытой секции нет в логе — момент появления новой секции незаметен") } if strings.Contains(out, "СЕКРЕТНЫЙ-РИТМ") { t.Error("содержимое непокрытой секции утекло в лог") } var rec struct { Level string `json:"level"` Uncovered []string `json:"uncovered"` } line := strings.TrimSpace(out) if i := strings.LastIndex(line, "\n"); i >= 0 { line = line[i+1:] } if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("запись лога не разбирается: %v", err) } if rec.Level != "INFO" { t.Errorf("уровень %q, ожидался INFO: частичность — не отклонение", rec.Level) } if len(rec.Uncovered) != 1 || rec.Uncovered[0] != "ecg" { t.Errorf("атрибут uncovered = %v, ожидался структурный список из ecg", rec.Uncovered) } } // Содержимое сущности чувствительнее значения точки: маршрут тренировки — это // геотрек до дома, а метки состояния разума — измерение душевного состояния. // Разрешены только координаты: род, идентификатор, интервал. func TestFoldНеПишетСодержимогоСущностейВЛог(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) f := fold.New(arch, st, 0, log) ctx := context.Background() const full = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` + `"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` + `"route":[{"latitude":55.987654,"longitude":37.123456},{"latitude":55.987655,"longitude":37.123457}],` + `"totalEnergy":{"qty":404040.4}}],` + `"stateOfMind":[{"id":"e-открытый","start":"2025-06-05T18:00:00Z",` + `"labels":["СЕКРЕТНАЯ-ЭМОЦИЯ"],"valence":0.777777}]}}` // Вторая доставка теряет маршрут и меняет значения — та самая ветка, где // пишется WARN об удержанной версии и где велик соблазн приписать «что // именно потерялось». const poorer = `{"data":{"workouts":[{"id":"w-открытый","name":"На улице Ходьба",` + `"start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300",` + `"totalEnergy":{"qty":505050.5}}]}}` deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(full)) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("свёртка первой доставки: %v", err) } deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(poorer)) if _, err := f.Fold(ctx, "d2"); err != nil { t.Fatalf("свёртка второй доставки: %v", err) } logged := buf.String() for _, secret := range []string{"55.98", "37.12", "latitude", "СЕКРЕТНАЯ-ЭМОЦИЯ", "404040", "505050", "0.777777"} { if strings.Contains(logged, secret) { t.Errorf("в логе оказалось %q:\n%s", secret, logged) } } // Координаты, наоборот, обязаны быть: без них счётчик удержанных версий не // говорит, какая сущность пострадала. if !strings.Contains(logged, "w-открытый") { t.Error("координат удержанной сущности в логе нет") } if !strings.Contains(logged, "poorer entity version held") { t.Error("удержание обеднённой версии не отмечено записью WARN") } } // Счётчики разбора доезжают до лога ручным присваиванием, и забытая строка // молча выключила бы наблюдение — тот самый класс, ради которого счётчики // слияния встроены структурой. Тест закрепляет имена атрибутов. func TestFoldСчётчикиСущностейДоезжаютДоЛога(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) f := fold.New(arch, st, 0, log) // Одна годная тренировка, одна без `id`, одна с неразбираемой меткой и один // элемент, не являющийся объектом: три класса пропуска плюс успех. const body = `{"data":{"workouts":[ {"id":"w1","start":"2025-06-05 10:00:00 +0300","end":"2025-06-05 10:10:00 +0300"}, {"start":"2025-06-05 11:00:00 +0300"}, {"id":"w3","start":"позавчера"}, "строка вместо объекта"]}}` deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(body)) if _, err := f.Fold(context.Background(), "d1"); err != nil { t.Fatalf("свёртка: %v", err) } var rec struct { Workouts int `json:"workouts"` WorkoutsWritten int `json:"workouts_written"` Records int `json:"records"` RecordsWritten int `json:"records_written"` EntitiesHeld int `json:"entities_held"` SkippedEntities int `json:"skipped_entities"` } line := strings.TrimSpace(buf.String()) if i := strings.LastIndex(line, "\n"); i >= 0 { line = line[i+1:] } if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("запись лога не разбирается: %v", err) } if rec.Workouts != 1 || rec.WorkoutsWritten != 1 { t.Errorf("тренировок %d, записано %d — ожидалось 1 и 1", rec.Workouts, rec.WorkoutsWritten) } if rec.SkippedEntities != 3 { t.Errorf("пропущено сущностей %d, ожидалось 3 — счётчик не доехал до лога", rec.SkippedEntities) } if rec.Records != 0 || rec.RecordsWritten != 0 || rec.EntitiesHeld != 0 { t.Errorf("лишние счётчики: записей %d/%d, удержано %d", rec.Records, rec.RecordsWritten, rec.EntitiesHeld) } } // Единственное направление, в котором новое правило теряет содержание, не // молчит: сохранённая была надмножеством по именам, но значения общих ключей // разошлись — разряд полноты погас, и содержание унесла пришедшая. // // От несравнимости этот случай отличается тем, что объединять поля тут было бы // нечего: у пришедшей своего ключа нет, она просто беднее. func TestFoldПотеряСодержанияДаётWarn(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, nil)) dir := t.TempDir() arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("архив: %v", err) } st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("база: %v", err) } t.Cleanup(func() { _ = st.Close() }) f := fold.New(arch, st, 0, log) ctx := context.Background() const body = `{"data":{"metrics":[{"name":"blood_glucose","units":"mg/dL","data":[` + `{"date":"2025-06-05 10:00:00 +0300",%s}` + `]}]}}` deliver(t, arch, st, "d1", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":5.1,"mealTime":"До еды"`))) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("первая свёртка: %v", err) } buf.Reset() // Беднее по именам И с другим значением общего ключа: Relate объявит пару // равнополной, и защитить `mealTime` будет некому. deliver(t, arch, st, "d2", "Minutes", "auto-1", []byte(fmt.Sprintf(body, `"qty":6.2`))) stats, err := f.Fold(ctx, "d2") if err != nil { t.Fatalf("вторая свёртка: %v", err) } if stats.PointsErased != 1 { t.Fatalf("потерь содержания %d, ожидалась 1: тест проверяет не то", stats.PointsErased) } if stats.Incomparable != 0 { t.Fatalf("несравнимых %d, ожидалось 0: случай перепутан с несравнимостью", stats.Incomparable) } var rec map[string]any if err := json.Unmarshal([]byte(strings.TrimSpace(buf.String())), &rec); err != nil { t.Fatalf("строка лога не JSON: %v\n%s", err, buf.String()) } if rec["level"] != "WARN" { t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String()) } if rec["msg"] != "delivery folded, arriving point dropped stored field" { t.Errorf("сообщение %v не называет событие", rec["msg"]) } if rec["points_erased"] != float64(1) { t.Errorf("счётчик потерь %v, ожидался 1", rec["points_erased"]) } at, _ := rec["points_erased_at"].(string) if !strings.Contains(at, "blood_glucose/minute@") { t.Errorf("координаты объекта в записи не те: %q", at) } // Ни значения точки, ни потерянной строки: `mealTime` — контекст измерения, // то есть данные о здоровье. Проверяется по разобранной записи, а не по // сырому буферу. delete(rec, "time") for k, v := range rec { if s, ok := v.(string); ok && strings.Contains(s, "До еды") { t.Errorf("в поле %q оказалось значение точки: %q", k, s) } } }