добавлен приём пакетов Health Auto Export

- POST /api/v1/ingest: токен, лимит тела, gzip, durable-запись тела в сырой
  архив, учёт доставки в SQLite
- код ответа отражает доставку, а не разбор: битый JSON — 400, непонятое
  содержимое — 200, данные уже сохранены и доразберутся позже
- сохраняется полный набор заголовков запроса с вычисткой секретов по имени
  и по совпадению значения с токеном
This commit is contained in:
av
2026-08-01 12:37:17 +03:00
parent 5e2385ba6e
commit 41f2f05f2f
20 changed files with 1903 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
package ingest_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"
"path/filepath"
"testing"
"git.vakhrushev.me/av/healthlog/internal/archive"
"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)
}
})
}
}
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, log), arch, st
}