package handler import ( "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) var handlerStructuredLogCaptureMu sync.Mutex type handlerInMemoryLogSink struct { mu sync.Mutex events []*logger.LogEvent } func (s *handlerInMemoryLogSink) WriteLogEvent(event *logger.LogEvent) { if event == nil { return } cloned := *event if event.Fields != nil { cloned.Fields = make(map[string]any, len(event.Fields)) for k, v := range event.Fields { cloned.Fields[k] = v } } s.mu.Lock() s.events = append(s.events, &cloned) s.mu.Unlock() } func (s *handlerInMemoryLogSink) ContainsMessageAtLevel(substr, level string) bool { s.mu.Lock() defer s.mu.Unlock() wantLevel := strings.ToLower(strings.TrimSpace(level)) for _, ev := range s.events { if ev == nil { continue } if strings.Contains(ev.Message, substr) && strings.ToLower(strings.TrimSpace(ev.Level)) == wantLevel { return true } } return false } func (s *handlerInMemoryLogSink) ContainsFieldValue(field, substr string) bool { s.mu.Lock() defer s.mu.Unlock() for _, ev := range s.events { if ev == nil || ev.Fields == nil { continue } if v, ok := ev.Fields[field]; ok && strings.Contains(fmt.Sprint(v), substr) { return true } } return false } func captureHandlerStructuredLog(t *testing.T) (*handlerInMemoryLogSink, func()) { t.Helper() handlerStructuredLogCaptureMu.Lock() err := logger.Init(logger.InitOptions{ Level: "debug", Format: "json", ServiceName: "sub2api", Environment: "test", Output: logger.OutputOptions{ ToStdout: true, ToFile: false, }, Sampling: logger.SamplingOptions{Enabled: false}, }) require.NoError(t, err) sink := &handlerInMemoryLogSink{} logger.SetSink(sink) return sink, func() { logger.SetSink(nil) handlerStructuredLogCaptureMu.Unlock() } } func TestIsOpenAIRemoteCompactPath(t *testing.T) { require.False(t, isOpenAIRemoteCompactPath(nil)) gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil) require.True(t, isOpenAIRemoteCompactPath(c)) c.Request = httptest.NewRequest(http.MethodPost, "/responses/compact/", nil) require.True(t, isOpenAIRemoteCompactPath(c)) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) require.False(t, isOpenAIRemoteCompactPath(c)) } func TestLogOpenAIRemoteCompactOutcome_Succeeded(t *testing.T) { gin.SetMode(gin.TestMode) logSink, restore := captureHandlerStructuredLog(t) defer restore() rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil) c.Request.Header.Set("User-Agent", "codex_cli_rs/0.104.0") c.Set(opsModelKey, "gpt-5.3-codex") c.Set(opsAccountIDKey, int64(123)) c.Header("x-request-id", "rid-compact-ok") c.Status(http.StatusOK) h := &OpenAIGatewayHandler{} h.logOpenAIRemoteCompactOutcome(c, time.Now().Add(-8*time.Millisecond)) require.True(t, logSink.ContainsMessageAtLevel("codex.remote_compact.succeeded", "info")) require.True(t, logSink.ContainsFieldValue("compact_outcome", "succeeded")) require.True(t, logSink.ContainsFieldValue("status_code", "200")) require.True(t, logSink.ContainsFieldValue("path", "/v1/responses/compact")) require.True(t, logSink.ContainsFieldValue("request_model", "gpt-5.3-codex")) require.True(t, logSink.ContainsFieldValue("account_id", "123")) require.True(t, logSink.ContainsFieldValue("upstream_request_id", "rid-compact-ok")) } func TestLogOpenAIRemoteCompactOutcome_Failed(t *testing.T) { gin.SetMode(gin.TestMode) logSink, restore := captureHandlerStructuredLog(t) defer restore() rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/responses/compact", nil) c.Request.Header.Set("User-Agent", "codex_cli_rs/0.104.0") c.Status(http.StatusBadGateway) h := &OpenAIGatewayHandler{} h.logOpenAIRemoteCompactOutcome(c, time.Now()) require.True(t, logSink.ContainsMessageAtLevel("codex.remote_compact.failed", "warn")) require.True(t, logSink.ContainsFieldValue("compact_outcome", "failed")) require.True(t, logSink.ContainsFieldValue("status_code", "502")) require.True(t, logSink.ContainsFieldValue("path", "/responses/compact")) } func TestLogOpenAIRemoteCompactOutcome_NonCompactSkips(t *testing.T) { gin.SetMode(gin.TestMode) logSink, restore := captureHandlerStructuredLog(t) defer restore() rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) c.Status(http.StatusOK) h := &OpenAIGatewayHandler{} h.logOpenAIRemoteCompactOutcome(c, time.Now()) require.False(t, logSink.ContainsMessageAtLevel("codex.remote_compact.succeeded", "info")) require.False(t, logSink.ContainsMessageAtLevel("codex.remote_compact.failed", "warn")) } func TestOpenAIResponses_CompactUnauthorizedLogsFailed(t *testing.T) { gin.SetMode(gin.TestMode) logSink, restore := captureHandlerStructuredLog(t) defer restore() rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", strings.NewReader(`{"model":"gpt-5.3-codex"}`)) c.Request.Header.Set("Content-Type", "application/json") c.Request.Header.Set("User-Agent", "codex_cli_rs/0.104.0") h := &OpenAIGatewayHandler{} h.Responses(c) require.Equal(t, http.StatusUnauthorized, rec.Code) require.True(t, logSink.ContainsMessageAtLevel("codex.remote_compact.failed", "warn")) require.True(t, logSink.ContainsFieldValue("status_code", "401")) require.True(t, logSink.ContainsFieldValue("path", "/v1/responses/compact")) }