package ingest_test import ( "context" "crypto/sha256" "encoding/hex" "errors" "io" "log/slog" "os" "path/filepath" "testing" "git.vakhrushev.me/av/healthlog/internal/archive" "git.vakhrushev.me/av/healthlog/internal/fold" "git.vakhrushev.me/av/healthlog/internal/ingest" "git.vakhrushev.me/av/healthlog/internal/store" ) func TestAcceptStoresBodyVerbatim(t *testing.T) { svc, arch, st := newService(t) // Незнакомая метрика и неизвестная форма точки — не повод отвергать пакет. body := []byte(`{"data":{"metrics":[{"name":"новая_метрика","units":"?","data":[{"чего_то":1,"date":"2026-07-31 12:00:00 +0300"}]}]}}`) res, err := svc.Accept(context.Background(), body, ingest.Meta{ AutomationName: "всё подряд", Aggregation: "None", Period: "Since Last Sync", }) if err != nil { t.Fatalf("Accept: %v", err) } if res.Bytes != int64(len(body)) { t.Errorf("bytes = %d, ожидалось %d", res.Bytes, len(body)) } sum := sha256.Sum256(body) if res.SHA256 != hex.EncodeToString(sum[:]) { t.Errorf("sha256 = %q", res.SHA256) } // Тело в архиве — байт в байт то, что прислали. r, err := arch.Open(res.RawPath) if err != nil { t.Fatalf("Open: %v", err) } defer func() { _ = r.Close() }() got, err := io.ReadAll(r) if err != nil { t.Fatalf("чтение архива: %v", err) } if string(got) != string(body) { t.Errorf("в архиве %q, прислано %q", got, body) } n, err := st.CountDeliveries(context.Background()) if err != nil { t.Fatalf("CountDeliveries: %v", err) } if n != 1 { t.Errorf("доставок в БД: %d, ожидалась 1", n) } } // Повтор того же тела пока принимается — отсев идентичных доставок отложен // (docs/plan.md). Проверяем, что повтор не ломается и не затирает первую. func TestAcceptAllowsRepeatedBody(t *testing.T) { svc, _, st := newService(t) body := []byte(`{"data":{"metrics":[]}}`) first, err := svc.Accept(context.Background(), body, ingest.Meta{}) if err != nil { t.Fatalf("первый Accept: %v", err) } second, err := svc.Accept(context.Background(), body, ingest.Meta{}) if err != nil { t.Fatalf("второй Accept: %v", err) } if first.DeliveryID == second.DeliveryID { t.Error("у двух доставок совпали идентификаторы") } if first.SHA256 != second.SHA256 { t.Error("у одинаковых тел разошёлся sha256") } n, err := st.CountDeliveries(context.Background()) if err != nil { t.Fatalf("CountDeliveries: %v", err) } if n != 2 { t.Errorf("доставок в БД: %d, ожидалось 2", n) } } func TestAcceptRejectsMalformed(t *testing.T) { cases := map[string]string{ "пустое тело": ``, "не JSON": `не json`, "обрезанный JSON": `{"data":{"metrics":[`, "нет data": `{"metrics":[]}`, "data не объект": `{"data":[]}`, } for name, body := range cases { t.Run(name, func(t *testing.T) { svc, _, st := newService(t) _, err := svc.Accept(context.Background(), []byte(body), ingest.Meta{}) if !errors.Is(err, ingest.ErrMalformed) { t.Fatalf("ошибка = %v, ожидалась ErrMalformed", err) } // Отвергнутая доставка не оставляет следа в учёте. n, err := st.CountDeliveries(context.Background()) if err != nil { t.Fatalf("CountDeliveries: %v", err) } if n != 0 { t.Errorf("доставок в БД: %d, ожидалось 0", n) } }) } } // Разбор не влияет на исход приёма: сохранили — значит приняли. Непонятое // содержимое даёт принятую доставку с parse_status=failed, а не отказ. func TestAcceptНепонятоеСодержимоеПринимается(t *testing.T) { svc, _, st := newService(t) ctx := context.Background() // Метрика есть, но слой определить нечем: плотных метрик нет, заголовок // ничего не означает, наследовать не от чего. body := []byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[` + `{"date":"2026-07-31 12:00:00 +0300","qty":1}]}]}}`) if _, err := svc.Accept(ctx, body, ingest.Meta{Aggregation: "Default"}); err != nil { t.Fatalf("Accept отверг доставку из-за разбора: %v", err) } d, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("LastDelivery: %v", err) } if d.ParseStatus != store.ParseFailed { t.Errorf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParseFailed) } } // Разобранная доставка отмечается разобранной, и точки доезжают до объектов. func TestAcceptРазобраннаяДоставкаОтмечена(t *testing.T) { svc, _, st := newService(t) ctx := context.Background() body, err := os.ReadFile(filepath.Join("..", "hae", "testdata", "minute.json")) if err != nil { t.Fatalf("фикстура: %v", err) } if _, err := svc.Accept(ctx, body, ingest.Meta{Aggregation: "Minutes"}); err != nil { t.Fatalf("Accept: %v", err) } d, err := st.LastDelivery(ctx) if err != nil { t.Fatalf("LastDelivery: %v", err) } if d.ParseStatus != store.ParseDone { t.Fatalf("parse_status = %q, ожидался %q", d.ParseStatus, store.ParseDone) } if d.Points == 0 { t.Error("точек 0: разбор не дошёл до учёта") } n, err := st.CountBuckets(ctx) if err != nil { t.Fatalf("CountBuckets: %v", err) } if n == 0 { t.Error("объектов 0: точки не доехали до хранилища") } } // Свёртка идёт на контексте, отвязанном от запроса (context.WithoutCancel в // Accept), чтобы обрыв соединения не оставил часть объектов записанной. // Автотестом это не покрыто: отмену надо подать РОВНО между учётом доставки и // свёрткой, а такого шва снаружи нет, и заводить его ради теста дороже, чем // проверять глазами. Атомарность самой записи проверена в store // (TestMergePointsОтменаНеОставляетПоловины). func newService(t *testing.T) (*ingest.Service, *archive.Archive, *store.Store) { t.Helper() dir := t.TempDir() st, err := store.Open(filepath.Join(dir, "healthlog.db")) if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { _ = st.Close() }) arch, err := archive.New(filepath.Join(dir, "raw")) if err != nil { t.Fatalf("archive.New: %v", err) } log := slog.New(slog.DiscardHandler) return ingest.New(arch, st, fold.New(arch, st, log), log), arch, st }