- internal/fold — свёртка по идентификатору доставки, тело из архива: тот же код, каким пойдёт пересборка витрины - приём зовёт свёртку на context.WithoutCancel с собственным дедлайном; исход разбора на код ответа не влияет - слой доставки хранится в delivery.derived_layer и наследуется от ПРЕДШЕСТВУЮЩЕЙ доставки автоматизации: без границы по времени свёртка переставала быть функцией от префикса журнала (1737 объектов против 1742) - task verify:archive — сходимость на живом архиве, 99 доставок из 99
209 lines
7.0 KiB
Go
209 lines
7.0 KiB
Go
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
|
||
}
|