package fold_test import ( "bytes" "context" "encoding/json" "log/slog" "path/filepath" "strings" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/fold" "git.vakhrushev.me/av/healthlog/internal/store" ) // Тело с обеими наблюдаемыми формами: фаза сна, которую словарь знает, и // контекст пульса, которого он не знает. const categoricalBody = `{"data":{"metrics":[` + `{"name":"sleep_analysis","units":"hr","data":[` + `{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` + `"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"Во сне"}]},` + `{"name":"heart_rate","units":"count/min","data":[` + `{"date":"2025-06-05 10:00:00 +0300","Avg":62,"context":"Сидячий образ жизни"}]}` + `]}}` // receivedAt — фиксированная метка приёма. // // Не `store.Now()`: провенанс первой встречи входит в отпечаток витрины, и // метка от часов сделала бы сравнение отпечатков между подтестами функцией // того, в одну ли секунду они успели отработать. Ровно так этот тест и покраснел // на гейте — у трёх подтестов из четырёх метки совпали, у четвёртого нет. var receivedAt = time.Date(2025, 6, 5, 7, 0, 0, 0, time.UTC) func deliverWithHeaders(t *testing.T, arch *archive.Archive, st *store.Store, id, headers string, body []byte) { t.Helper() at := receivedAt rawPath, err := arch.Write(id, at, body) if err != nil { t.Fatalf("запись в архив: %v", err) } err = st.CreateDelivery(context.Background(), store.Delivery{ ID: id, ReceivedAt: at, AutomationID: "auto-1", Aggregation: "Hours", Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath, Headers: headers, ParseStatus: store.ParsePending, }) if err != nil { t.Fatalf("запись доставки: %v", err) } } func foldService(t *testing.T, log *slog.Logger) (*fold.Service, *archive.Archive, *store.Store) { t.Helper() 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() }) return fold.New(arch, st, 0, log), arch, st } func TestFoldКладётНаблюденияВРеестр(t *testing.T) { t.Parallel() f, arch, st := foldService(t, slog.New(slog.DiscardHandler)) deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody)) stats, err := f.Fold(context.Background(), "d1") if err != nil { t.Fatalf("свёртка: %v", err) } if stats.Categoricals != 2 { t.Errorf("наблюдений %d, ожидалось 2", stats.Categoricals) } if stats.CategoricalUnknown != 1 { t.Errorf("строк без кода %d, ожидалась 1 (контекст пульса)", stats.CategoricalUnknown) } got, err := st.CategoryValues(context.Background()) if err != nil { t.Fatalf("чтение реестра: %v", err) } if len(got) != 2 { t.Fatalf("строк реестра %d: %+v", len(got), got) } if got[1].Value != "Во сне" || got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" { t.Errorf("фаза сна в реестре: %+v", got[1]) } if got[0].Value != "Сидячий образ жизни" || got[0].Code != "" { t.Errorf("контекст пульса в реестре: %+v", got[0]) } if got[1].FirstDeliveryID != "d1" { t.Errorf("провенанс %q, ожидался d1", got[1].FirstDeliveryID) } } // Локаль сужает поиск по словарю, но в ключ не входит и вывода не отменяет: // заголовков в сыром архиве нет, и доставка, восстановленная из осиротевшего // тела, обязана дать то же состояние. func TestFoldЛокальНеМеняетСостояния(t *testing.T) { t.Parallel() cases := []struct{ name, headers string }{ {"измеренный заголовок потока", `{"Accept-Language":["ru"]}`}, {"подтег, веса и регистр", `{"accept-language":["RU-ru,ru;q=0.9,en;q=0.8"]}`}, {"заголовка нет вовсе", `{}`}, {"незнакомая локаль", `{"Accept-Language":["de"]}`}, // Пустая строка и битый JSON — не вычурность: заголовков в сыром архиве // нет вовсе, и доставка, восстановленная из осиротевшего тела, приезжает // ровно так. Оба обязаны дать пустую локаль и то же состояние. {"заголовков нет вовсе", ``}, {"заголовки не разбираются", `не json`}, {"заголовок пустым списком", `{"Accept-Language":[]}`}, } var want string for _, c := range cases { t.Run(c.name, func(t *testing.T) { f, arch, st := foldService(t, slog.New(slog.DiscardHandler)) deliverWithHeaders(t, arch, st, "d1", c.headers, []byte(categoricalBody)) if _, err := f.Fold(context.Background(), "d1"); err != nil { t.Fatalf("свёртка: %v", err) } got, err := st.CategoryValues(context.Background()) if err != nil { t.Fatalf("чтение реестра: %v", err) } if len(got) != 2 { t.Fatalf("строк реестра %d: %+v", len(got), got) } if got[1].Code != "HKCategoryValueSleepAnalysisAsleepUnspecified" { t.Errorf("код фазы сна %q — локаль отменила вывод", got[1].Code) } // Отпечаток не должен зависеть от заголовка вовсе: ключ реестра — // функция одних тел. fp, err := st.Fingerprint(context.Background()) if err != nil { t.Fatalf("отпечаток: %v", err) } if want == "" { want = fp } else if fp != want { t.Errorf("заголовок %s сдвинул отпечаток витрины", c.headers) } }) } } // Строки категориальных значений — данные о здоровье наравне со значением // точки: «Сидячий образ жизни» описывает человека. В журнал уходят только // счётчики. // // Запись РАЗБИРАЕТСЯ, служебное `time` выбрасывается, и поиск идёт в остатке: // метка времени содержит произвольные цифры, и поиск по сырому буферу делает // такой тест флаки по построению (запись 2026-08-02 в docs/review.md). func TestFoldНеПишетКатегориальныхСтрокВЛог(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) f, arch, st := foldService(t, log) deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(categoricalBody)) if _, err := f.Fold(context.Background(), "d1"); err != nil { t.Fatalf("свёртка: %v", err) } logged := strings.TrimSpace(buf.String()) if logged == "" { t.Fatal("свёртка не записала ни одной строки — чекпоинт молчит") } var folded map[string]any for line := range strings.SplitSeq(logged, "\n") { var rec map[string]any if err := json.Unmarshal([]byte(line), &rec); err != nil { t.Fatalf("строка лога не JSON: %v", err) } delete(rec, "time") clean, err := json.Marshal(rec) if err != nil { t.Fatalf("запись лога не сериализуется: %v", err) } for _, secret := range []string{"Во сне", "Сидячий образ жизни", "HKCategoryValue"} { if strings.Contains(string(clean), secret) { t.Errorf("в логе оказалось %q:\n%s", secret, clean) } } if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "delivery folded") { folded = rec } } if folded == nil { t.Fatalf("записи об исходе свёртки нет:\n%s", logged) } for _, attr := range []string{"categoricals", "categoricals_unknown", "categoricals_dropped"} { if _, ok := folded[attr]; !ok { t.Errorf("в записи нет счётчика %q: %v", attr, folded) } } if folded["categoricals_unknown"] != float64(1) { t.Errorf("счётчик строк без кода %v, ожидалась 1", folded["categoricals_unknown"]) } } // Срабатывание границы наблюдений обязано подниматься до WARN: границы // подобраны по измерению, поэтому попадание в них — аномалия, а не режим, и в // INFO оно утонуло бы среди штатных доставок раз в пять минут. func TestFoldПревышениеГраницыДаётWarn(t *testing.T) { t.Parallel() var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) f, arch, st := foldService(t, log) // Одно непомерно длинное значение — граница длины, а не числа: тело // остаётся маленьким, а событие то же. long := strings.Repeat("я", 200) body := `{"data":{"metrics":[{"name":"sleep_analysis","units":"hr","data":[` + `{"date":"2025-06-05 10:00:00 +0300","start":"2025-06-05 10:00:00 +0300",` + `"end":"2025-06-05 11:00:00 +0300","qty":1,"value":"` + long + `"}]}]}}` deliverWithHeaders(t, arch, st, "d1", `{"Accept-Language":["ru"]}`, []byte(body)) stats, err := f.Fold(context.Background(), "d1") if err != nil { t.Fatalf("свёртка: %v", err) } if stats.CategoricalDropped == 0 { t.Fatal("граница не сработала — тест проверяет не то") } var rec map[string]any for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") { var r map[string]any if err := json.Unmarshal([]byte(line), &r); err != nil { t.Fatalf("строка лога не JSON: %v", err) } if msg, _ := r["msg"].(string); strings.HasPrefix(msg, "delivery folded") { rec = r } } if rec == nil { t.Fatalf("записи об исходе свёртки нет:\n%s", buf.String()) } if rec["level"] != "WARN" { t.Errorf("уровень %v, ожидался WARN:\n%s", rec["level"], buf.String()) } if rec["msg"] != "delivery folded, categorical values dropped by limit" { t.Errorf("сообщение %v не называет событие", rec["msg"]) } // И ни одного байта самой строки: она из тела доставки. delete(rec, "time") clean, err := json.Marshal(rec) if err != nil { t.Fatalf("запись лога не сериализуется: %v", err) } if strings.Contains(string(clean), "яяя") { t.Errorf("в логе оказалось значение из тела:\n%s", clean) } }