package fold_test import ( "context" "log/slog" "os" "path/filepath" "testing" "time" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/fold" "git.vakhrushev.me/av/healthlog/internal/store" ) func newFold(t *testing.T) (*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() }) log := slog.New(slog.DiscardHandler) return fold.New(arch, st, 0, log), arch, st } // deliver кладёт тело в архив и заводит доставку — ровно то, что делает приём. func deliver(t *testing.T, arch *archive.Archive, st *store.Store, id, aggregation, automationID string, body []byte) { t.Helper() at := store.Now() 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: automationID, Aggregation: aggregation, Bytes: int64(len(body)), SHA256: "-", RawPath: rawPath, ParseStatus: store.ParsePending, }) if err != nil { t.Fatalf("запись доставки: %v", err) } } func fixture(t *testing.T, name string) []byte { t.Helper() body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", name)) if err != nil { t.Fatalf("фикстура %s: %v", name, err) } return body } // Свёртка читает тело из архива по идентификатору доставки — тем же кодом, // каким его прочитает пересборка витрины. func TestFoldРаскладываетТочкиПоОбъектам(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json")) stats, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("свёртка: %v", err) } if stats.Points == 0 || stats.Buckets == 0 { t.Fatalf("свёртка ничего не разложила: %+v", stats) } if stats.Layer != "minute" { t.Errorf("слой доставки %q, ожидался minute", stats.Layer) } n, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } if n != int64(stats.Buckets) { t.Errorf("объектов в базе %d, свёртка насчитала %d", n, stats.Buckets) } } // Слой сохраняется, чтобы его унаследовала следующая доставка той же // автоматизации: без плотных метрик выводить его не из чего, а после // перезапуска сервиса память об этом иначе теряется. func TestFoldСлойНаследуетсяВнутриАвтоматизации(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json")) if _, err := f.Fold(ctx, "d1"); err != nil { t.Fatalf("первая свёртка: %v", err) } // Доставка без плотных метрик и с бесполезным заголовком: сама по себе // слой не выводится. deliver(t, arch, st, "d2", "Default", "auto-1", fixture(t, "sparse_sleep.json")) stats, err := f.Fold(ctx, "d2") if err != nil { t.Fatalf("вторая свёртка: %v", err) } if stats.Layer != "minute" { t.Errorf("слой %q, ожидался унаследованный minute", stats.Layer) } } // Наследовать нечего, заголовок ненадёжен: точки не сохраняются, доставка // помечается неразобранной и ждёт пересборки. Молчаливый выбор raw создал бы // призрачный разрез. func TestFoldНеопределимыйСлойНеПишетТочек(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Default", "auto-1", fixture(t, "sparse_sleep.json")) if _, err := f.Fold(ctx, "d1"); err == nil { t.Fatal("свёртка с неопределимым слоем прошла успешно") } n, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } if n != 0 { t.Errorf("записано %d объектов при неизвестном слое", n) } } // Повторная свёртка той же доставки не меняет состояния: журнал можно // проигрывать сколько угодно раз. func TestFoldИдемпотентен(t *testing.T) { t.Parallel() f, arch, st := newFold(t) ctx := context.Background() deliver(t, arch, st, "d1", "Minutes", "auto-1", fixture(t, "minute.json")) first, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("первая свёртка: %v", err) } second, err := f.Fold(ctx, "d1") if err != nil { t.Fatalf("повторная свёртка: %v", err) } if second.Unchanged != second.Buckets { t.Errorf("изменилось %d объектов из %d при повторе того же тела", second.Buckets-second.Unchanged, second.Buckets) } if first.Buckets != second.Buckets { t.Errorf("объектов %d против %d", first.Buckets, second.Buckets) } } // Порядок воспроизведения доставок не влияет на итог: свёртка по журналу // обязана давать то же состояние, что приём в реальном времени. func TestFoldНеЗависитОтПорядкаДоставок(t *testing.T) { t.Parallel() state := func(order []string) (int64, string) { f, arch, st := newFold(t) ctx := context.Background() bodies := map[string]string{"a": "minute.json", "b": "hour.json", "c": "raw.json"} aggs := map[string]string{"a": "Minutes", "b": "Hours", "c": "Default"} for _, id := range order { deliver(t, arch, st, id, aggs[id], "auto-"+id, fixture(t, bodies[id])) if _, err := f.Fold(ctx, id); err != nil { t.Fatalf("свёртка %s: %v", id, err) } } n, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("счёт объектов: %v", err) } b, err := st.Bucket(ctx, "active_energy", "minute", mustHour(t, st, "active_energy", "minute")) if err != nil { return n, "" } return n, b.Hash } nForward, hForward := state([]string{"a", "b", "c"}) nBackward, hBackward := state([]string{"c", "b", "a"}) if nForward != nBackward { t.Errorf("объектов %d против %d при разном порядке доставок", nForward, nBackward) } if hForward != hBackward { t.Errorf("содержимое разошлось: %s против %s", hForward, hBackward) } } // mustHour находит час, в котором лежит объект метрики: фикстуры сдвинуты по // времени, и зашивать конкретную дату в тест значило бы ломать его при каждой // пересборке набора. func mustHour(t *testing.T, st *store.Store, metric, layer string) time.Time { t.Helper() hours, err := st.BucketHours(context.Background(), metric, layer) if err != nil { t.Fatalf("часы объекта: %v", err) } if len(hours) == 0 { t.Fatalf("объектов метрики %s слоя %s нет", metric, layer) } return hours[0] }