package helps import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor") func TestRequestLoggingDoesNotMarkUpstreamAttempt(t *testing.T) { tests := []struct { name string record func(context.Context) }{ { name: "HTTP", record: func(ctx context.Context) { RecordAPIRequest(ctx, &config.Config{}, UpstreamRequestLog{URL: "https://api.example.com", Method: http.MethodPost}) }, }, { name: "websocket", record: func(ctx context.Context) { RecordAPIWebsocketRequest(ctx, &config.Config{}, UpstreamRequestLog{URL: "wss://api.example.com", Method: "WEBSOCKET"}) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { ctx := cliproxyexecutor.WithUpstreamAttemptTracker(context.Background()) test.record(ctx) if cliproxyexecutor.UpstreamAttempted(ctx) { t.Fatal("request logging marked an upstream attempt before transport") } }) }} func TestRecordAPIRequestClonesDeferredBodyWhenRequestLogDisabled(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(recorder) ctx := context.WithValue(context.Background(), "gin", ginCtx) body := []byte(`{"model":"original"}`) RecordAPIRequest(ctx, &config.Config{}, UpstreamRequestLog{ URL: "https://api.example.com/v1/responses", Method: http.MethodPost, Body: body, }) body[10] = 'X' value, exists := ginCtx.Get(logging.DeferredAPIRequestContextKey) if !exists { t.Fatal("deferred API request was not captured") } requests, ok := value.([]logging.DeferredAPIRequest) if !ok || len(requests) != 1 { t.Fatalf("deferred API requests = %#v, want one request", value) } captured := string(requests[0]()) if !strings.Contains(captured, `{"model":"original"}`) { t.Fatalf("captured API request = %q, want original body", captured) }} func TestRecordAPIResponseMetadataStoresHeadersWhenRequestLogDisabled(t *testing.T) { ctx := logging.WithResponseHeadersHolder(context.Background()) headers := http.Header{} headers.Add("X-Upstream-Request-Id", "upstream-req-1") RecordAPIResponseMetadata(ctx, &config.Config{}, http.StatusOK, headers) headers.Set("X-Upstream-Request-Id", "mutated") got := logging.GetResponseHeaders(ctx) if got.Get("X-Upstream-Request-Id") != "upstream-req-1" { t.Fatalf("response header = %q, want %q", got.Get("X-Upstream-Request-Id"), "upstream-req-1") }} func TestAPIResponseAttemptsAreSeparated(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string fileBacked bool firstResponseBody []byte }{ {name: "memory backed error"}, {name: "file backed error", fileBacked: true}, {name: "memory backed partial body", firstResponseBody: []byte("partial")}, {name: "file backed partial body", fileBacked: true, firstResponseBody: []byte("partial")}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { recorder := httptest.NewRecorder() ginCtx, _ := gin.CreateTestContext(recorder) var responseSource *logging.FileBodySource if tt.fileBacked { var errSource error responseSource, errSource = logging.NewFileBodySourceInDir(t.TempDir(), "api-response") if errSource != nil { t.Fatalf("NewFileBodySourceInDir: %v", errSource) } t.Cleanup(func() { if errCleanup := responseSource.Cleanup(); errCleanup != nil { t.Errorf("Cleanup: %v", errCleanup) } }) ginCtx.Set(logging.APIResponseSourceContextKey, responseSource) } ctx := context.WithValue(context.Background(), "gin", ginCtx) cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}} RecordAPIRequest(ctx, cfg, UpstreamRequestLog{URL: "https://api.example.com/first", Method: http.MethodPost}) if len(tt.firstResponseBody) > 0 { AppendAPIResponseChunk(ctx, cfg, tt.firstResponseBody) } else { RecordAPIResponseError(ctx, cfg, errors.New("EOF")) } RecordAPIRequest(ctx, cfg, UpstreamRequestLog{URL: "https://api.example.com/second", Method: http.MethodPost}) RecordAPIResponseError(ctx, cfg, errors.New("retry failed")) var response []byte if responseSource != nil { var errBytes error response, errBytes = responseSource.Bytes() if errBytes != nil { t.Fatalf("responseSource.Bytes: %v", errBytes) } } else { value, exists := ginCtx.Get(apiResponseKey) if !exists { t.Fatal("API_RESPONSE was not captured") } response, _ = value.([]byte) } previousEnd := "Error: EOF" if len(tt.firstResponseBody) > 0 { previousEnd = string(tt.firstResponseBody) } wantBoundary := previousEnd + "\n\n=== API RESPONSE 2 ===" if !strings.Contains(string(response), wantBoundary) { t.Fatalf("API response attempts are not separated by one blank line:\n%q\nwant boundary %q", response, wantBoundary) } }) }}
package helps
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
func TestRequestLoggingDoesNotMarkUpstreamAttempt(t *testing.T) {
tests := []struct {
name string
record func(context.Context)
}{
{
name: "HTTP",
record: func(ctx context.Context) {
RecordAPIRequest(ctx, &config.Config{}, UpstreamRequestLog{URL: "https://api.example.com", Method: http.MethodPost})
},
},
{
name: "websocket",
record: func(ctx context.Context) {
RecordAPIWebsocketRequest(ctx, &config.Config{}, UpstreamRequestLog{URL: "wss://api.example.com", Method: "WEBSOCKET"})
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := cliproxyexecutor.WithUpstreamAttemptTracker(context.Background())
test.record(ctx)
if cliproxyexecutor.UpstreamAttempted(ctx) {
t.Fatal("request logging marked an upstream attempt before transport")
}
})
}
}
func TestRecordAPIRequestClonesDeferredBodyWhenRequestLogDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
ctx := context.WithValue(context.Background(), "gin", ginCtx)
body := []byte(`{"model":"original"}`)
RecordAPIRequest(ctx, &config.Config{}, UpstreamRequestLog{
URL: "https://api.example.com/v1/responses",
Method: http.MethodPost,
Body: body,
})
body[10] = 'X'
value, exists := ginCtx.Get(logging.DeferredAPIRequestContextKey)
if !exists {
t.Fatal("deferred API request was not captured")
}
requests, ok := value.([]logging.DeferredAPIRequest)
if !ok || len(requests) != 1 {
t.Fatalf("deferred API requests = %#v, want one request", value)
}
captured := string(requests[0]())
if !strings.Contains(captured, `{"model":"original"}`) {
t.Fatalf("captured API request = %q, want original body", captured)
}
}
func TestRecordAPIResponseMetadataStoresHeadersWhenRequestLogDisabled(t *testing.T) {
ctx := logging.WithResponseHeadersHolder(context.Background())
headers := http.Header{}
headers.Add("X-Upstream-Request-Id", "upstream-req-1")
RecordAPIResponseMetadata(ctx, &config.Config{}, http.StatusOK, headers)
headers.Set("X-Upstream-Request-Id", "mutated")
got := logging.GetResponseHeaders(ctx)
if got.Get("X-Upstream-Request-Id") != "upstream-req-1" {
t.Fatalf("response header = %q, want %q", got.Get("X-Upstream-Request-Id"), "upstream-req-1")
}
}
func TestAPIResponseAttemptsAreSeparated(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
fileBacked bool
firstResponseBody []byte
}{
{name: "memory backed error"},
{name: "file backed error", fileBacked: true},
{name: "memory backed partial body", firstResponseBody: []byte("partial")},
{name: "file backed partial body", fileBacked: true, firstResponseBody: []byte("partial")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
var responseSource *logging.FileBodySource
if tt.fileBacked {
var errSource error
responseSource, errSource = logging.NewFileBodySourceInDir(t.TempDir(), "api-response")
if errSource != nil {
t.Fatalf("NewFileBodySourceInDir: %v", errSource)
}
t.Cleanup(func() {
if errCleanup := responseSource.Cleanup(); errCleanup != nil {
t.Errorf("Cleanup: %v", errCleanup)
}
})
ginCtx.Set(logging.APIResponseSourceContextKey, responseSource)
}
ctx := context.WithValue(context.Background(), "gin", ginCtx)
cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}}
RecordAPIRequest(ctx, cfg, UpstreamRequestLog{URL: "https://api.example.com/first", Method: http.MethodPost})
if len(tt.firstResponseBody) > 0 {
AppendAPIResponseChunk(ctx, cfg, tt.firstResponseBody)
} else {
RecordAPIResponseError(ctx, cfg, errors.New("EOF"))
}
RecordAPIRequest(ctx, cfg, UpstreamRequestLog{URL: "https://api.example.com/second", Method: http.MethodPost})
RecordAPIResponseError(ctx, cfg, errors.New("retry failed"))
var response []byte
if responseSource != nil {
var errBytes error
response, errBytes = responseSource.Bytes()
if errBytes != nil {
t.Fatalf("responseSource.Bytes: %v", errBytes)
}
} else {
value, exists := ginCtx.Get(apiResponseKey)
if !exists {
t.Fatal("API_RESPONSE was not captured")
}
response, _ = value.([]byte)
}
previousEnd := "Error: EOF"
if len(tt.firstResponseBody) > 0 {
previousEnd = string(tt.firstResponseBody)
}
wantBoundary := previousEnd + "\n\n=== API RESPONSE 2 ==="
if !strings.Contains(string(response), wantBoundary) {
t.Fatalf("API response attempts are not separated by one blank line:\n%q\nwant boundary %q", response, wantBoundary)
}
})
}
}