2026-02-07 10:03:04 +08:00
package service
import (
"bytes"
"context"
"encoding/json"
2026-06-12 07:24:04 +08:00
"errors"
2026-02-07 10:03:04 +08:00
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyErrorPassthroughRule_NoBoundService ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
status , errType , errMsg , matched := applyErrorPassthroughRule (
c ,
PlatformAnthropic ,
http . StatusUnprocessableEntity ,
[] byte ( `{"error":{"message":"invalid schema"}}` ),
http . StatusBadGateway ,
"upstream_error" ,
"Upstream request failed" ,
)
assert . False ( t , matched )
assert . Equal ( t , http . StatusBadGateway , status )
assert . Equal ( t , "upstream_error" , errType )
assert . Equal ( t , "Upstream request failed" , errMsg )
}
func TestGatewayHandleErrorResponse_NoRuleKeepsDefault ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
svc := & GatewayService {}
respBody := [] byte ( `{"error":{"message":"Invalid schema for field messages"}}` )
resp := & http . Response {
StatusCode : http . StatusUnprocessableEntity ,
Body : io . NopCloser ( bytes . NewReader ( respBody )),
Header : http . Header {},
}
account := & Account { ID : 11 , Platform : PlatformAnthropic , Type : AccountTypeAPIKey }
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account )
require . Error ( t , err )
assert . Equal ( t , http . StatusBadGateway , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok )
assert . Equal ( t , "upstream_error" , errField [ "type" ])
assert . Equal ( t , "Upstream request failed" , errField [ "message" ])
}
func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
svc := & OpenAIGatewayService {}
respBody := [] byte ( `{"error":{"message":"Invalid schema for field messages"}}` )
resp := & http . Response {
StatusCode : http . StatusUnprocessableEntity ,
Body : io . NopCloser ( bytes . NewReader ( respBody )),
Header : http . Header {},
}
account := & Account { ID : 12 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey }
2026-02-13 19:27:07 +08:00
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account , nil )
2026-02-07 10:03:04 +08:00
require . Error ( t , err )
assert . Equal ( t , http . StatusBadGateway , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok )
assert . Equal ( t , "upstream_error" , errField [ "type" ])
assert . Equal ( t , "Upstream request failed" , errField [ "message" ])
}
2026-06-12 07:24:04 +08:00
func TestOpenAIHandleErrorResponse_ContextWindow502KeepsMessageWithoutFailover ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
c . Request = httptest . NewRequest ( http . MethodPost , "/" , nil )
svc := & OpenAIGatewayService {}
respBody := [] byte ( `{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"upstream_error","code":null}}` )
resp := & http . Response {
StatusCode : http . StatusBadGateway ,
Body : io . NopCloser ( bytes . NewReader ( respBody )),
Header : http . Header {},
}
account := & Account { ID : 14 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey }
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account , nil )
require . Error ( t , err )
var failoverErr * UpstreamFailoverError
require . False ( t , errors . As ( err , & failoverErr ))
assert . Equal ( t , http . StatusBadGateway , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok )
assert . Equal ( t , "upstream_error" , errField [ "type" ])
assert . Equal ( t , "Your input exceeds the context window of this model. Please adjust your input and try again." , errField [ "message" ])
}
2026-02-07 10:03:04 +08:00
func TestGeminiWriteGeminiMappedError_NoRuleKeepsDefault ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
svc := & GeminiMessagesCompatService {}
respBody := [] byte ( `{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}` )
account := & Account { ID : 13 , Platform : PlatformGemini , Type : AccountTypeAPIKey }
err := svc . writeGeminiMappedError ( c , account , http . StatusUnprocessableEntity , "req-2" , respBody )
require . Error ( t , err )
assert . Equal ( t , http . StatusBadRequest , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok )
assert . Equal ( t , "invalid_request_error" , errField [ "type" ])
assert . Equal ( t , "Upstream request failed" , errField [ "message" ])
}
func TestGatewayHandleErrorResponse_AppliesRuleFor422 ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
ruleSvc := & ErrorPassthroughService {}
ruleSvc . setLocalCache ([] * model . ErrorPassthroughRule { newNonFailoverPassthroughRule ( http . StatusUnprocessableEntity , "invalid schema" , http . StatusTeapot , "上游请求失败" )})
BindErrorPassthroughService ( c , ruleSvc )
svc := & GatewayService {}
respBody := [] byte ( `{"error":{"message":"Invalid schema for field messages"}}` )
resp := & http . Response {
StatusCode : http . StatusUnprocessableEntity ,
Body : io . NopCloser ( bytes . NewReader ( respBody )),
Header : http . Header {},
}
account := & Account { ID : 1 , Platform : PlatformAnthropic , Type : AccountTypeAPIKey }
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account )
require . Error ( t , err )
assert . Equal ( t , http . StatusTeapot , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok )
assert . Equal ( t , "upstream_error" , errField [ "type" ])
assert . Equal ( t , "上游请求失败" , errField [ "message" ])
}
func TestOpenAIHandleErrorResponse_AppliesRuleFor422 ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
ruleSvc := & ErrorPassthroughService {}
ruleSvc . setLocalCache ([] * model . ErrorPassthroughRule { newNonFailoverPassthroughRule ( http . StatusUnprocessableEntity , "invalid schema" , http . StatusTeapot , "OpenAI上游失败" )})
BindErrorPassthroughService ( c , ruleSvc )
svc := & OpenAIGatewayService {}
respBody := [] byte ( `{"error":{"message":"Invalid schema for field messages"}}` )
resp := & http . Response {
StatusCode : http . StatusUnprocessableEntity ,
Body : io . NopCloser ( bytes . NewReader ( respBody )),
Header : http . Header {},
}
account := & Account { ID : 2 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey }
2026-02-13 19:27:07 +08:00
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account , nil )
2026-02-07 10:03:04 +08:00
require . Error ( t , err )
assert . Equal ( t , http . StatusTeapot , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok )
assert . Equal ( t , "upstream_error" , errField [ "type" ])
assert . Equal ( t , "OpenAI上游失败" , errField [ "message" ])
}
func TestGeminiWriteGeminiMappedError_AppliesRuleFor422 ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
ruleSvc := & ErrorPassthroughService {}
ruleSvc . setLocalCache ([] * model . ErrorPassthroughRule { newNonFailoverPassthroughRule ( http . StatusUnprocessableEntity , "invalid schema" , http . StatusTeapot , "Gemini上游失败" )})
BindErrorPassthroughService ( c , ruleSvc )
svc := & GeminiMessagesCompatService {}
respBody := [] byte ( `{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}` )
account := & Account { ID : 3 , Platform : PlatformGemini , Type : AccountTypeAPIKey }
err := svc . writeGeminiMappedError ( c , account , http . StatusUnprocessableEntity , "req-1" , respBody )
require . Error ( t , err )
assert . Equal ( t , http . StatusTeapot , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok )
assert . Equal ( t , "upstream_error" , errField [ "type" ])
assert . Equal ( t , "Gemini上游失败" , errField [ "message" ])
}
2026-02-10 11:42:39 +08:00
func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
rule := newNonFailoverPassthroughRule ( http . StatusBadRequest , "prompt is too long" , http . StatusBadRequest , "上下文超限" )
rule . SkipMonitoring = true
ruleSvc := & ErrorPassthroughService {}
ruleSvc . setLocalCache ([] * model . ErrorPassthroughRule { rule })
BindErrorPassthroughService ( c , ruleSvc )
_ , _ , _ , matched := applyErrorPassthroughRule (
c ,
PlatformAnthropic ,
http . StatusBadRequest ,
[] byte ( `{"error":{"message":"prompt is too long"}}` ),
http . StatusBadGateway ,
"upstream_error" ,
"Upstream request failed" ,
)
assert . True ( t , matched )
v , exists := c . Get ( OpsSkipPassthroughKey )
assert . True ( t , exists , "OpsSkipPassthroughKey should be set when skip_monitoring=true" )
2026-02-10 22:08:49 +08:00
boolVal , ok := v .( bool )
assert . True ( t , ok , "value should be bool" )
assert . True ( t , boolVal )
2026-02-10 11:42:39 +08:00
}
func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
rule := newNonFailoverPassthroughRule ( http . StatusBadRequest , "prompt is too long" , http . StatusBadRequest , "上下文超限" )
rule . SkipMonitoring = false
ruleSvc := & ErrorPassthroughService {}
ruleSvc . setLocalCache ([] * model . ErrorPassthroughRule { rule })
BindErrorPassthroughService ( c , ruleSvc )
_ , _ , _ , matched := applyErrorPassthroughRule (
c ,
PlatformAnthropic ,
http . StatusBadRequest ,
[] byte ( `{"error":{"message":"prompt is too long"}}` ),
http . StatusBadGateway ,
"upstream_error" ,
"Upstream request failed" ,
)
assert . True ( t , matched )
_ , exists := c . Get ( OpsSkipPassthroughKey )
assert . False ( t , exists , "OpsSkipPassthroughKey should NOT be set when skip_monitoring=false" )
}
2026-06-09 14:52:32 +08:00
// ---- ResponseCommittedKey: service 层写完错误响应后标记,handler 层检查跳过兜底写入 ----
func TestHandleErrorResponse_SetsResponseCommitted ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
svc := & GatewayService {}
resp := & http . Response {
StatusCode : http . StatusBadRequest ,
Body : io . NopCloser ( bytes . NewReader ([] byte ( `{"error":{"message":"temperature: range: 0..1"}}` ))),
Header : http . Header {},
}
account := & Account { ID : 100 , Platform : PlatformAnthropic , Type : AccountTypeAPIKey }
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account )
require . Error ( t , err )
assert . True ( t , IsResponseCommitted ( c ), "non-failover error path must mark response committed" )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
}
func TestHandleErrorResponse_PassthroughRuleSetsCommitted ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
ruleSvc := & ErrorPassthroughService {}
ruleSvc . setLocalCache ([] * model . ErrorPassthroughRule {
newNonFailoverPassthroughRule ( http . StatusBadRequest , "temperature" , http . StatusBadRequest , "参数错误" ),
})
BindErrorPassthroughService ( c , ruleSvc )
svc := & GatewayService {}
resp := & http . Response {
StatusCode : http . StatusBadRequest ,
Body : io . NopCloser ( bytes . NewReader ([] byte ( `{"error":{"message":"temperature: range: 0..1"}}` ))),
Header : http . Header {},
}
account := & Account { ID : 200 , Platform : PlatformAnthropic , Type : AccountTypeAPIKey }
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account )
require . Error ( t , err )
assert . True ( t , IsResponseCommitted ( c ), "passthrough rule path must mark response committed" )
assert . Equal ( t , http . StatusBadRequest , rec . Code )
var payload map [ string ] any
require . NoError ( t , json . Unmarshal ( rec . Body . Bytes (), & payload ))
2026-06-10 00:46:19 +08:00
errField , ok := payload [ "error" ].( map [ string ] any )
require . True ( t , ok , "payload[\"error\"] should be map[string]any" )
2026-06-09 14:52:32 +08:00
assert . Equal ( t , "参数错误" , errField [ "message" ])
}
func TestOpenAIHandleErrorResponse_SetsResponseCommitted ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
svc := & OpenAIGatewayService {}
resp := & http . Response {
StatusCode : http . StatusTooManyRequests ,
Body : io . NopCloser ( bytes . NewReader ([] byte ( `{"error":{"message":"rate limit exceeded"}}` ))),
Header : http . Header {},
}
account := & Account { ID : 101 , Platform : PlatformOpenAI , Type : AccountTypeAPIKey }
_ , err := svc . handleErrorResponse ( context . Background (), resp , c , account , nil )
require . Error ( t , err )
assert . True ( t , IsResponseCommitted ( c ), "OpenAI non-failover path must mark response committed" )
}
func TestGeminiWriteGeminiMappedError_SetsResponseCommitted ( t * testing . T ) {
gin . SetMode ( gin . TestMode )
rec := httptest . NewRecorder ()
c , _ := gin . CreateTestContext ( rec )
svc := & GeminiMessagesCompatService {}
body := [] byte ( `{"error":{"message":"invalid field"}}` )
account := & Account { ID : 102 , Platform : PlatformGemini , Type : AccountTypeAPIKey }
err := svc . writeGeminiMappedError ( c , account , http . StatusBadRequest , "req-99" , body )
require . Error ( t , err )
assert . True ( t , IsResponseCommitted ( c ), "Gemini path must mark response committed" )
}
2026-02-07 10:03:04 +08:00
func newNonFailoverPassthroughRule ( statusCode int , keyword string , respCode int , customMessage string ) * model . ErrorPassthroughRule {
return & model . ErrorPassthroughRule {
ID : 1 ,
Name : "non-failover-rule" ,
Enabled : true ,
Priority : 1 ,
ErrorCodes : [] int { statusCode },
Keywords : [] string { keyword },
MatchMode : model . MatchModeAll ,
PassthroughCode : false ,
ResponseCode : & respCode ,
PassthroughBody : false ,
CustomMessage : & customMessage ,
}
}