package compatibility import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "os" "testing" "github.com/edgeai/gateway/internal/config" "github.com/edgeai/gateway/internal/observability" "github.com/edgeai/gateway/internal/server" "github.com/edgeai/gateway/pkg/api" ) var compatServer *httptest.Server func TestMain(m *testing.M) { cfg := &config.Config{ Server: config.ServerConfig{Host: "127.0.0.1", Port: 0, MaxRequestBodyMB: 5}, Auth: config.AuthConfig{Enabled: true, Methods: []string{"api_key"}}, Scheduler: config.SchedulerConfig{ MaxRunningTasks: 2, MaxQueuedTasks: 10, }, Timeouts: config.TimeoutConfig{ DefaultQueueMs: 2000, DefaultInferenceMs: 10000, DefaultTotalMs: 15000, }, Context: config.ContextConfig{SafetyMarginRatio: 0.1, DefaultPolicy: "recent_only"}, Models: map[string]config.ModelConfig{ "general-chat": { Provider: "ollama", ActualModel: "qwen2.5:0.5b", Endpoint: "http://127.0.0.1:11434", ContextWindow: 4096, MaxOutputTokens: 256, CancelSupported: true, }, "fast-chat": { Provider: "ollama", ActualModel: "qwen2.5:0.5b", Endpoint: "http://127.0.0.1:11434", ContextWindow: 2048, MaxOutputTokens: 128, CancelSupported: true, }, }, Observability: config.ObservabilityConfig{MetricsPath: "/metrics", LogLevel: "error"}, Storage: config.StorageConfig{ SessionDB: "sqlite:///tmp/edgeai-compat/sessions.db", TaskState: "sqlite:///tmp/edgeai-compat/tasks.db", }, } os.MkdirAll("/tmp/edgeai-compat", 0755) defer os.RemoveAll("/tmp/edgeai-compat") logger := observability.NewLogger(observability.LevelError, os.Stderr, "metadata_only") srv, err := server.New(cfg, logger) if err != nil { panic("failed to create compat test server: " + err.Error()) } compatServer = httptest.NewServer(srv.HTTPSrv.Handler) defer compatServer.Close() m.Run() } // COMPAT-001: GET /v1/models returns OpenAI-compatible format func TestCompat001_ModelsFormat(t *testing.T) { req, _ := http.NewRequest("GET", compatServer.URL+"/v1/models", nil) req.Header.Set("Authorization", "Bearer test-key") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 401 { // If auth passes (unlikely without real key), check format var result api.ModelListResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { t.Fatalf("failed to decode models response: %v", err) } if result.Object != "list" { t.Errorf("expected object 'list', got %s", result.Object) } for _, m := range result.Data { if m.Object != "model" { t.Errorf("expected object 'model', got %s", m.Object) } } } } // COMPAT-002: Chat request format matches OpenAI API func TestCompat002_ChatRequestFormat(t *testing.T) { // Verify the request body structure is OpenAI-compatible req := api.ChatRequest{ Model: "general-chat", Messages: []api.Message{ {Role: "system", Content: "You are a helpful assistant."}, {Role: "user", Content: "Hello!"}, }, Stream: false, } data, err := json.Marshal(req) if err != nil { t.Fatal(err) } // Verify JSON structure var raw map[string]any json.Unmarshal(data, &raw) requiredFields := []string{"model", "messages"} for _, field := range requiredFields { if _, ok := raw[field]; !ok { t.Errorf("required field %q missing from chat request", field) } } // Verify messages structure msgs, ok := raw["messages"].([]any) if !ok || len(msgs) != 2 { t.Fatalf("expected 2 messages, got %v", raw["messages"]) } firstMsg, ok := msgs[0].(map[string]any) if !ok { t.Fatal("expected message to be object") } if firstMsg["role"] != "system" { t.Errorf("expected role 'system', got %v", firstMsg["role"]) } if firstMsg["content"] != "You are a helpful assistant." { t.Errorf("unexpected content: %v", firstMsg["content"]) } } // COMPAT-003: Chat response format matches OpenAI API func TestCompat003_ChatResponseFormat(t *testing.T) { resp := api.ChatResponse{ RequestID: "req-123", TaskID: "task-456", Status: "completed", Model: "general-chat", Choices: []api.Choice{ { Index: 0, Message: &api.Message{ Role: "assistant", Content: "Hello! How can I help you?", }, FinishReason: "stop", }, }, Usage: &api.Usage{ InputTokens: 10, OutputTokens: 8, TotalTokens: 18, }, } data, err := json.Marshal(resp) if err != nil { t.Fatal(err) } var raw map[string]any json.Unmarshal(data, &raw) // Verify OpenAI-compatible fields if _, ok := raw["choices"]; !ok { t.Error("choices field missing from response") } if _, ok := raw["model"]; !ok { t.Error("model field missing from response") } } // COMPAT-004: SSE streaming format matches OpenAI API func TestCompat004_SSEFormat(t *testing.T) { // Verify SSE chunk format chunk := map[string]any{ "id": "req-123", "object": "chat.completion.chunk", "model": "general-chat", "choices": []map[string]any{ { "index": 0, "delta": map[string]any{ "content": "Hello", }, "finish_reason": nil, }, }, } data, err := json.Marshal(chunk) if err != nil { t.Fatal(err) } var raw map[string]any json.Unmarshal(data, &raw) if raw["object"] != "chat.completion.chunk" { t.Errorf("expected object 'chat.completion.chunk', got %v", raw["object"]) } } // COMPAT-005: Error response format matches OpenAI API func TestCompat005_ErrorFormat(t *testing.T) { req, _ := http.NewRequest("GET", compatServer.URL+"/v1/models", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() var buf bytes.Buffer buf.ReadFrom(resp.Body) var errResp map[string]any json.Unmarshal(buf.Bytes(), &errResp) // OpenAI format: {"error": {"code": ..., "message": ...}} errBody, ok := errResp["error"].(map[string]any) if !ok { t.Fatal("expected 'error' object in response") } if _, ok := errBody["code"]; !ok { t.Error("expected 'code' field in error") } if _, ok := errBody["message"]; !ok { t.Error("expected 'message' field in error") } } // COMPAT-006: Session API follows REST conventions func TestCompat006_SessionREST(t *testing.T) { // POST /v1/sessions creates a session createReq, _ := http.NewRequest("POST", compatServer.URL+"/v1/sessions", nil) createReq.Header.Set("Content-Type", "application/json") createReq.Header.Set("Authorization", "Bearer test-key") createReq.Body = io.NopCloser(bytes.NewReader([]byte(`{"application_id":"test-app"}`))) createResp, err := http.DefaultClient.Do(createReq) if err != nil { t.Fatal(err) } createResp.Body.Close() // Should be 201 (Created) or 401 (auth) if createResp.StatusCode != 201 && createResp.StatusCode != 401 { t.Errorf("expected 201 or 401 for POST /v1/sessions, got %d", createResp.StatusCode) } // DELETE /v1/sessions/:id deletes a session deleteReq, _ := http.NewRequest("DELETE", compatServer.URL+"/v1/sessions/test-id", nil) deleteReq.Header.Set("Authorization", "Bearer test-key") deleteResp, err := http.DefaultClient.Do(deleteReq) if err != nil { t.Fatal(err) } deleteResp.Body.Close() // Should be 200, 401, or 400 (not found) if deleteResp.StatusCode == 500 { t.Error("expected non-500 for DELETE session") } } // COMPAT-007: Multiple models in config are all listed func TestCompat007_MultipleModels(t *testing.T) { // Verify config has multiple models cfg := &config.Config{ Models: map[string]config.ModelConfig{ "general-chat": {Provider: "ollama", ActualModel: "a"}, "fast-chat": {Provider: "ollama", ActualModel: "b"}, }, } if len(cfg.Models) != 2 { t.Errorf("expected 2 models, got %d", len(cfg.Models)) } } // COMPAT-008: curl-compatible request (no extra headers needed) func TestCompat008_CurlCompatible(t *testing.T) { // Simulate a curl request with minimal headers req, _ := http.NewRequest("GET", compatServer.URL+"/health", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Errorf("expected 200 for simple curl-like request, got %d", resp.StatusCode) } }