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()) } }