Files
easyai-ai-gateway/apps/api/internal/httpapi/compat_protocol_test.go
T
easyai e07a997aa9 feat(api): 统一官方兼容接口响应协议
兼容接口现在以入口协议作为最终响应协议,同协议保留官方 Wire 响应,跨协议统一转换成功、任务状态与错误结构。

同时修正异步提交状态边界,持久化兼容公开任务标识和官方提交响应,并新增迁移、流式响应及协议契约测试。

验证:go vet ./...;go test ./...;govulncheck ./...;pnpm lint;pnpm test;pnpm build;pnpm audit --audit-level high;pnpm openapi;全部 CI 脚本。
2026-07-22 15:34:59 +08:00

177 lines
6.3 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestProtocolErrorsUseOfficialShapesWithoutGatewayExtensions(t *testing.T) {
tests := []struct {
name string
protocol string
status int
assertBody func(*testing.T, map[string]any)
}{
{
name: "openai", protocol: clients.ProtocolOpenAIChatCompletions, status: http.StatusBadRequest,
assertBody: func(t *testing.T, body map[string]any) {
errorBody := requireObject(t, body["error"])
if errorBody["type"] != "invalid_request_error" || errorBody["code"] != "unsupported_kind" {
t.Fatalf("unexpected OpenAI error: %+v", body)
}
assertNoKeys(t, errorBody, "status", "retryable", "taskId", "gateway_status")
},
},
{
name: "gemini", protocol: clients.ProtocolGeminiGenerateContent, status: http.StatusTooManyRequests,
assertBody: func(t *testing.T, body map[string]any) {
errorBody := requireObject(t, body["error"])
if errorBody["code"] != float64(http.StatusTooManyRequests) || errorBody["status"] != "RESOURCE_EXHAUSTED" {
t.Fatalf("unexpected Gemini error: %+v", body)
}
assertNoKeys(t, errorBody, "retryable", "taskId", "gateway_status")
},
},
{
name: "volces", protocol: clients.ProtocolVolcesContents, status: http.StatusBadGateway,
assertBody: func(t *testing.T, body map[string]any) {
errorBody := requireObject(t, body["error"])
if errorBody["code"] != "upstream_submission_unknown" {
t.Fatalf("unexpected Volces error: %+v", body)
}
assertNoKeys(t, errorBody, "status", "retryable", "taskId", "gateway_status")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
code := "unsupported_kind"
if test.name == "gemini" {
code = "rate_limit"
} else if test.name == "volces" {
code = "upstream_submission_unknown"
}
writeProtocolError(recorder, test.protocol, test.status, "failed", nil, code)
if recorder.Code != test.status {
t.Fatalf("status=%d, want %d", recorder.Code, test.status)
}
var body map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
test.assertBody(t, body)
})
}
}
func TestWireResponsePassthroughPreservesStatusUnknownFieldsAndAllowedHeaders(t *testing.T) {
recorder := httptest.NewRecorder()
wire := &clients.WireResponse{
Protocol: clients.ProtocolOpenAIResponses,
StatusCode: http.StatusAccepted,
Headers: map[string][]string{
"Content-Type": {"application/json"},
"X-Request-Id": {"req_official"},
"Retry-After": {"2"},
},
RawJSON: []byte(`{"id":"resp_1","future_official_field":{"v":1}}`),
}
writeWireResponse(recorder, wire)
if recorder.Code != http.StatusAccepted || recorder.Header().Get("X-Request-Id") != "req_official" || recorder.Header().Get("Retry-After") != "2" {
t.Fatalf("wire metadata was not preserved: status=%d headers=%v", recorder.Code, recorder.Header())
}
if got := strings.TrimSpace(recorder.Body.String()); got != string(wire.RawJSON) {
t.Fatalf("wire body changed: %s", got)
}
if !wireResponseMatches(wire, clients.ProtocolOpenAIResponses) {
t.Fatal("native unconverted wire response must be eligible for passthrough")
}
wire.Converted = true
if wireResponseMatches(wire, clients.ProtocolOpenAIResponses) {
t.Fatal("converted wire response must not be eligible for passthrough")
}
}
func TestCompatibilityStatusMappings(t *testing.T) {
for internal, want := range map[string]string{
"queued": "queued", "running": "running", "succeeded": "succeeded", "failed": "failed", "cancelled": "cancelled",
} {
if got := volcesCompatibleTaskStatus(internal); got != want {
t.Fatalf("Volces status %q=%q, want %q", internal, got, want)
}
}
for internal, want := range map[string]string{
"queued": "submitted", "running": "processing", "succeeded": "succeed", "failed": "failed", "cancelled": "failed",
} {
if got := klingV1Status(internal); got != want {
t.Fatalf("Kling V1 status %q=%q, want %q", internal, got, want)
}
}
for internal, want := range map[string]string{
"queued": "submitted", "running": "processing", "succeeded": "succeeded", "failed": "failed", "cancelled": "failed",
} {
if got := klingV2Status(internal); got != want {
t.Fatalf("Kling V2 status %q=%q, want %q", internal, got, want)
}
}
}
func TestNativeOpenAIStreamPreservesUnknownEventFields(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
executor := &fakeTaskExecutor{
deltas: []clients.StreamDeltaEvent{{
Event: map[string]any{
"id": "chatcmpl_1", "object": "chat.completion.chunk",
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": "hi"}}},
"future_official_field": map[string]any{"enabled": true},
},
WireProtocol: clients.ProtocolOpenAIChatCompletions,
WireHeaders: map[string][]string{"X-Request-Id": {"req_stream_1"}},
}},
output: map[string]any{"id": "chatcmpl_1"},
}
writeProtocolCompatibleTaskResponse(
context.Background(), recorder, request, executor,
"chat.completions", "gpt-test", clients.ProtocolOpenAIChatCompletions,
store.GatewayTask{ID: "gateway-task"}, &auth.User{}, true, false,
)
if recorder.Header().Get("X-Request-Id") != "req_stream_1" {
t.Fatalf("official stream header was lost: %+v", recorder.Header())
}
body := recorder.Body.String()
if !strings.Contains(body, `"future_official_field":{"enabled":true}`) || strings.Count(body, "data: [DONE]") != 1 {
t.Fatalf("native stream event was reconstructed or duplicated: %s", body)
}
if strings.Contains(body, "gateway-task") {
t.Fatalf("native stream leaked gateway task id: %s", body)
}
}
func requireObject(t *testing.T, value any) map[string]any {
t.Helper()
result, ok := value.(map[string]any)
if !ok {
t.Fatalf("value is not an object: %#v", value)
}
return result
}
func assertNoKeys(t *testing.T, object map[string]any, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := object[key]; ok {
t.Fatalf("unexpected gateway extension %q in %+v", key, object)
}
}
}