package ingest_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/lethe/internal/config"
"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)
// fakeAuthMiddleware injects a fixed Identity onto the request context so the
// handler under test can call auth.MustIdentity without a real Authenticator.
func fakeAuthMiddleware(user string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := auth.WithIdentity(r.Context(), auth.Identity{User: user})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// newHandler wires Service + Repository + observability + handler against a
// fresh in-memory database, then returns the handler.
func newHandler(t *testing.T, cfg config.IngestConfig) *ingest.Handler {
t.Helper()
svc, _ := newService(t, cfg)
h := &ingest.Handler{Cfg: cfg, Service: svc}
if err := h.Init(context.Background()); err != nil {
t.Fatalf("handler.Init: %v", err)
}
return h
}
// mountHandler builds a chi router with the fake auth middleware and the
// ingest handler mounted under /api/v1.
func mountHandler(h *ingest.Handler, user string) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(r chi.Router) {
r.Use(fakeAuthMiddleware(user))
h.Mount(r)
})
return r
}
func TestHandler_Post_HappyPath(t *testing.T) {
h := newHandler(t, defaultCfg())
router := mountHandler(h, "alice")
payload := strings.Join([]string{validLine("t1", 1700000000, "x"), validLine("t2", 1700000001, "y")}, "\n") + "\n"
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(payload))
req.Header.Set("Content-Type", "application/x-ndjson")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var got ingest.Result
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Accepted != 2 {
t.Errorf("Accepted=%d; want 2", got.Accepted)
}
if len(got.Errors) != 0 {
t.Errorf("Errors=%v; want empty", got.Errors)
}
}
func TestHandler_Post_WrongContentTypeReturns415(t *testing.T) {
h := newHandler(t, defaultCfg())
router := mountHandler(h, "alice")
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest",
strings.NewReader(validLine("t1", 1700000000, "x")+"\n"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status=%d; want 415; body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("Content-Type=%q; want application/problem+json", ct)
}
}
func TestHandler_Post_MissingContentTypeReturns415(t *testing.T) {
h := newHandler(t, defaultCfg())
router := mountHandler(h, "alice")
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest",
strings.NewReader(validLine("t1", 1700000000, "x")+"\n"))
// No Content-Type set.
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status=%d; want 415", rec.Code)
}
}
func TestHandler_Post_BodyOverCapReturns413(t *testing.T) {
cfg := defaultCfg()
cfg.MaxBodyBytes = 100
h := newHandler(t, cfg)
router := mountHandler(h, "alice")
// 1 KiB+ body that exceeds the cap.
big := validLine("t1", 1700000000, strings.Repeat("a", 1024))
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest", strings.NewReader(big+"\n"))
req.Header.Set("Content-Type", "application/x-ndjson")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d; want 413; body=%s", rec.Code, rec.Body.String())
}
}
func TestHandler_Post_DBDownReturns5xx(t *testing.T) {
cfg := defaultCfg()
svc, db := newService(t, cfg)
h := &ingest.Handler{Cfg: cfg, Service: svc}
if err := h.Init(context.Background()); err != nil {
t.Fatalf("handler.Init: %v", err)
}
// Close the underlying *sql.DB without nilling the field so BeginTx
// returns an error rather than nil-dereferencing. This mirrors the
// runtime "database closed" failure mode (e.g. driver disconnect) more
// accurately than calling Destroy.
if err := db.DB.Close(); err != nil {
t.Fatalf("close underlying db: %v", err)
}
router := mountHandler(h, "alice")
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest",
strings.NewReader(validLine("t1", 1700000000, "x")+"\n"))
req.Header.Set("Content-Type", "application/x-ndjson")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code < 500 {
t.Fatalf("status=%d; want 5xx; body=%s", rec.Code, rec.Body.String())
}
}
// TestHandler_Post_ContentTypeWithCharset proves the canonical media check
// tolerates the `; charset=utf-8` parameter the collector may attach.
func TestHandler_Post_ContentTypeWithCharset(t *testing.T) {
h := newHandler(t, defaultCfg())
router := mountHandler(h, "alice")
req := httptest.NewRequest(http.MethodPost, "/api/v1/ingest",
strings.NewReader(validLine("t1", 1700000000, "x")+"\n"))
req.Header.Set("Content-Type", "application/x-ndjson; charset=utf-8")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String())
}
}