Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s

This commit is contained in:
李建琦
2026-08-21 18:30:13 +08:00
commit 6d655c9903
3584 changed files with 1270640 additions and 0 deletions
@@ -0,0 +1,184 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGatewayEnsureForwardErrorResponse_WritesFallbackWhenNotWritten(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.True(t, wrote)
require.Equal(t, http.StatusBadGateway, w.Code)
var parsed map[string]any
err := json.Unmarshal(w.Body.Bytes(), &parsed)
require.NoError(t, err)
assert.Equal(t, "error", parsed["type"])
errorObj, ok := parsed["error"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "upstream_error", errorObj["type"])
assert.Equal(t, "Upstream request failed", errorObj["message"])
}
// Writer 已写后 ensureForwardErrorResponse 必须把错误以 SSE 形式追加,
// 而不是 silent EOF。非 /responses 路径走 legacy data:{"type":"error"} 分支。
func TestGatewayEnsureForwardErrorResponse_AppendsSSEAfterWritten(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.String(http.StatusTeapot, "already written")
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.True(t, wrote)
require.Equal(t, http.StatusTeapot, w.Code)
assert.Contains(t, w.Body.String(), "already written")
assert.Contains(t, w.Body.String(), `data: {"type":"error"`)
}
func TestGatewayEnsureForwardErrorResponse_SkipsCommittedSSEError(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
c.Header("Content-Type", "text/event-stream")
_, _ = c.Writer.WriteString("event: error\ndata: {\"type\":\"error\"}\n\n")
service.MarkResponseCommitted(c)
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, true)
require.False(t, wrote)
require.Equal(t, 1, strings.Count(w.Body.String(), "event: error"))
}
// case B 回归:Anthropic-backed /responsesWriter 已被写过时
// ensureForwardErrorResponse 仍要发 response.failed。
func TestGatewayEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsResponseFailed(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
_, _ = c.Writer.WriteString(":\n\n")
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.True(t, wrote)
body := w.Body.String()
assert.Contains(t, body, ":\n\n")
assert.Contains(t, body, "event: response.failed\n")
assert.Contains(t, body, `"type":"response.failed"`)
}
func TestGatewayForwardErrorAlreadyCommunicated(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("json error already written", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil)
before := c.Writer.Size()
c.JSON(http.StatusBadGateway, gin.H{
"type": "error",
"error": gin.H{
"type": "upstream_error",
"message": "Your Claude Code version (2.1.39) is below the minimum required version (2.1.81). Please update: npm update -g @anthropic-ai/claude-code",
},
})
reported := gatewayForwardErrorAlreadyCommunicated(c, before, errors.New("upstream error: 400 message=version too low"))
require.True(t, reported)
body := w.Body.String()
assert.NotContains(t, body, `data: {"type":"error"`)
})
t.Run("sse ping still needs fallback", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil)
c.Header("Content-Type", "text/event-stream")
before := c.Writer.Size()
_, _ = c.Writer.WriteString(":\n\n")
reported := gatewayForwardErrorAlreadyCommunicated(c, before, errors.New("stream read error: unexpected EOF"))
require.False(t, reported)
})
t.Run("no write still needs fallback", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil)
reported := gatewayForwardErrorAlreadyCommunicated(c, c.Writer.Size(), errors.New("upstream request failed"))
require.False(t, reported)
})
// apikey 场景核心回归:复刻 GatewayService.handleErrorResponse 的 case 400 ——
// 原样透传上游 JSON body 后返回 err。此时错误已经完整告知客户端,
// handler 不得再追加 data:{"type":"error"} 帧,否则响应被污染成「JSON + 一行 data:」。
t.Run("upstream 400 json passthrough via c.Data", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil)
before := c.Writer.Size()
upstreamBody := []byte(`{"type":"error","error":{"type":"upstream_error","message":"Your Claude Code version (2.1.39) is below the minimum required version (2.1.81). Please update: npm update -g @anthropic-ai/claude-code"}}`)
c.Data(http.StatusBadRequest, "application/json", upstreamBody)
reported := gatewayForwardErrorAlreadyCommunicated(c, before, errors.New("upstream error: 400 message=version too low"))
require.True(t, reported)
body := w.Body.String()
assert.NotContains(t, body, `data: {"type":"error"`)
// 客户端只应收到上游那一份错误,没有被追加第二份。
assert.Equal(t, 1, strings.Count(body, `"type":"error"`))
})
// 流式已开始(已 flush 真实 SSE 事件,不只是 ping)+ 上游中途 400:
// HTTP 200 已固化,仍需 handler 补协议级终止帧,故不算「已完整告知」。
t.Run("streaming 400 mid-stream still needs fallback", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil)
c.Header("Content-Type", "text/event-stream")
before := c.Writer.Size()
_, _ = c.Writer.WriteString("event: message_start\ndata: {\"type\":\"message_start\"}\n\n")
reported := gatewayForwardErrorAlreadyCommunicated(c, before, errors.New("upstream error: 400 message=version too low"))
require.False(t, reported)
})
// 防御边界:err 为 nil 时永远不算「已告知」,避免在成功路径误吞兜底逻辑。
t.Run("nil error never reports communicated", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil)
c.JSON(http.StatusOK, gin.H{"ok": true})
reported := gatewayForwardErrorAlreadyCommunicated(c, 0, nil)
require.False(t, reported)
})
}