go-ml/api/routes_test.go
Snider a6fb45da67 refactor: apply go fix modernizers for Go 1.26
Automated fixes: interface{} → any, range-over-int, t.Context(),
wg.Go(), strings.SplitSeq, strings.Builder, slices.Contains,
maps helpers, min/max builtins.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-22 21:00:16 +00:00

263 lines
7.8 KiB
Go

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