Files
healthlog/internal/ingest/ingest_test.go
T
av 32f21044e4 свёртка доставки в объекты и сшивка с приёмом
- internal/fold — свёртка по идентификатору доставки, тело из архива: тот же
  код, каким пойдёт пересборка витрины
- приём зовёт свёртку на context.WithoutCancel с собственным дедлайном;
  исход разбора на код ответа не влияет
- слой доставки хранится в delivery.derived_layer и наследуется от
  ПРЕДШЕСТВУЮЩЕЙ доставки автоматизации: без границы по времени свёртка
  переставала быть функцией от префикса журнала (1737 объектов против 1742)
- task verify:archive — сходимость на живом архиве, 99 доставок из 99
2026-08-01 18:03:59 +03:00

209 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}