// SPDX-License-Identifier: EUPL-1.2 package api_test import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" goapi "forge.lthn.ai/core/go-api" mlapi "forge.lthn.ai/core/go-ml/api" "github.com/gin-gonic/gin" ) func init() { gin.SetMode(gin.TestMode) } // ── Interface satisfaction ───────────────────────────────────────────── func TestRoutes_Good_SatisfiesRouteGroup(t *testing.T) { var rg goapi.RouteGroup = mlapi.NewRoutes(nil) if rg.Name() != "ml" { t.Fatalf("expected Name=%q, got %q", "ml", rg.Name()) } if rg.BasePath() != "/v1/ml" { t.Fatalf("expected BasePath=%q, got %q", "/v1/ml", rg.BasePath()) } } func TestRoutes_Good_SatisfiesStreamGroup(t *testing.T) { var sg goapi.StreamGroup = mlapi.NewRoutes(nil) channels := sg.Channels() if len(channels) != 2 { t.Fatalf("expected 2 channels, got %d", len(channels)) } if channels[0] != "ml.generate" { t.Fatalf("expected channels[0]=%q, got %q", "ml.generate", channels[0]) } if channels[1] != "ml.status" { t.Fatalf("expected channels[1]=%q, got %q", "ml.status", channels[1]) } } // ── Engine integration ───────────────────────────────────────────────── func TestRoutes_Good_EngineRegistration(t *testing.T) { e, err := goapi.New() if err != nil { t.Fatalf("unexpected error: %v", err) } routes := mlapi.NewRoutes(nil) e.Register(routes) groups := e.Groups() if len(groups) != 1 { t.Fatalf("expected 1 group, got %d", len(groups)) } if groups[0].Name() != "ml" { t.Fatalf("expected group name=%q, got %q", "ml", groups[0].Name()) } } func TestRoutes_Good_EngineChannels(t *testing.T) { e, _ := goapi.New() routes := mlapi.NewRoutes(nil) e.Register(routes) channels := e.Channels() if len(channels) != 2 { t.Fatalf("expected 2 channels, got %d", len(channels)) } } // ── ListBackends handler ─────────────────────────────────────────────── func TestListBackends_Bad_NilService(t *testing.T) { routes := mlapi.NewRoutes(nil) h := buildHandler(routes) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/v1/ml/backends", nil) h.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) } var resp goapi.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false for nil service") } if resp.Error == nil { t.Fatal("expected Error to be set") } if resp.Error.Code != "SERVICE_UNAVAILABLE" { t.Fatalf("expected error code=%q, got %q", "SERVICE_UNAVAILABLE", resp.Error.Code) } } // ── Status handler ───────────────────────────────────────────────────── func TestStatus_Bad_NilService(t *testing.T) { routes := mlapi.NewRoutes(nil) h := buildHandler(routes) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/v1/ml/status", nil) h.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) } var resp goapi.Response[any] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if resp.Success { t.Fatal("expected Success=false for nil service") } } // ── Generate handler ─────────────────────────────────────────────────── func TestGenerate_Bad_NilService(t *testing.T) { routes := mlapi.NewRoutes(nil) h := buildHandler(routes) body := `{"prompt":"hello"}` w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/v1/ml/generate", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") h.ServeHTTP(w, req) if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) } } func TestGenerate_Bad_MissingPrompt(t *testing.T) { // Even with a nil service, request validation happens first when service // is nil — but our handler checks service first. So this tests a valid // scenario where the body is empty. routes := mlapi.NewRoutes(nil) h := buildHandler(routes) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/v1/ml/generate", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") h.ServeHTTP(w, req) // Service check happens before body parsing, so we get 503. if w.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d", w.Code) } } // ── Envelope format ──────────────────────────────────────────────────── func TestEnvelope_Good_ErrorFormat(t *testing.T) { routes := mlapi.NewRoutes(nil) h := buildHandler(routes) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/v1/ml/status", nil) h.ServeHTTP(w, req) // Verify the envelope has the correct JSON structure. var raw map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { t.Fatalf("unmarshal error: %v", err) } // Must have "success" key. if _, ok := raw["success"]; !ok { t.Fatal("envelope missing 'success' key") } // Must have "error" key for failure responses. if _, ok := raw["error"]; !ok { t.Fatal("envelope missing 'error' key for failure response") } // "data" should be absent or null for failure responses. if data, ok := raw["data"]; ok { var d any if err := json.Unmarshal(data, &d); err == nil && d != nil { t.Fatal("expected 'data' to be absent or null for failure response") } } } func TestEnvelope_Good_HealthViaEngine(t *testing.T) { // Verify that the built-in /health endpoint still works // when our ML routes are registered alongside it. e, _ := goapi.New() routes := mlapi.NewRoutes(nil) e.Register(routes) h := e.Handler() w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/health", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp goapi.Response[string] if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal error: %v", err) } if !resp.Success || resp.Data != "healthy" { t.Fatalf("expected healthy response, got success=%v data=%q", resp.Success, resp.Data) } } // ── Route method checks ──────────────────────────────────────────────── func TestRoutes_Bad_WrongMethod(t *testing.T) { routes := mlapi.NewRoutes(nil) h := buildHandler(routes) // POST to a GET-only endpoint. w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPost, "/v1/ml/backends", nil) h.ServeHTTP(w, req) if w.Code == http.StatusOK { t.Fatal("expected non-200 for POST on GET-only route") } } func TestRoutes_Bad_NotFound(t *testing.T) { routes := mlapi.NewRoutes(nil) h := buildHandler(routes) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/v1/ml/nonexistent", nil) h.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } // ── Helpers ──────────────────────────────────────────────────────────── // buildHandler creates an api.Engine with the given routes and returns its http.Handler. func buildHandler(routes goapi.RouteGroup) http.Handler { e, _ := goapi.New() e.Register(routes) return e.Handler() }