package main import ( "context" "encoding/json" "errors" "fmt" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gofiber/fiber/v3" libpack_cache "github.com/lukaszraczylo/graphql-monitoring-proxy/cache" "github.com/sony/gobreaker" "github.com/valyala/fasthttp" ) // --------------------------------------------------------------------------- // AddRequestUUID // --------------------------------------------------------------------------- func TestAddRequestUUID_SetsLocalsAndCallsNext(t *testing.T) { app := fiber.New() app.Use(AddRequestUUID) var captured string app.Get("/", func(c fiber.Ctx) error { if v, ok := c.Locals("request_uuid").(string); ok { captured = v } return c.SendStatus(200) }) req := httptest.NewRequest("GET", "/", nil) resp, err := app.Test(req, fiber.TestConfig{Timeout: 0}) if err != nil { t.Fatalf("app.Test: %v", err) } _ = resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("want 200, got %d", resp.StatusCode) } if captured == "" { t.Fatal("request_uuid not set in Locals") } // UUIDs are 36 chars (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) if len(captured) != 36 { t.Errorf("unexpected UUID length: %q", captured) } } func TestAddRequestUUID_UniquePerRequest(t *testing.T) { app := fiber.New() app.Use(AddRequestUUID) seen := make([]string, 0, 5) app.Get("/", func(c fiber.Ctx) error { if v, ok := c.Locals("request_uuid").(string); ok { seen = append(seen, v) } return c.SendStatus(200) }) for i := range 5 { req := httptest.NewRequest("GET", "/", nil) resp, err := app.Test(req, fiber.TestConfig{Timeout: 0}) if err != nil { t.Fatalf("request %d: %v", i, err) } _ = resp.Body.Close() } set := make(map[string]struct{}, len(seen)) for _, id := range seen { set[id] = struct{}{} } if len(set) != 5 { t.Errorf("expected 5 unique UUIDs, got %d unique in %v", len(set), seen) } } // --------------------------------------------------------------------------- // healthCheck // --------------------------------------------------------------------------- func TestHealthCheck_Returns200WithJSON(t *testing.T) { // Ensure cfg is ready and GraphQL check is disabled via query param parseConfig() _ = StartMonitoringServer() app := fiber.New() app.Get("/health", healthCheck) // Pass check_graphql=false to avoid real network call req := httptest.NewRequest("GET", "/health?check_graphql=false&check_redis=false", nil) resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond}) if err != nil { t.Fatalf("app.Test: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { t.Fatalf("want 200, got %d", resp.StatusCode) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode response: %v", err) } if _, ok := body["status"]; !ok { t.Error("response missing 'status' field") } if _, ok := body["timestamp"]; !ok { t.Error("response missing 'timestamp' field") } if body["status"] != "healthy" { t.Errorf("want status=healthy, got %v", body["status"]) } } func TestHealthCheck_UnhealthyWhenGraphQLDown(t *testing.T) { parseConfig() _ = StartMonitoringServer() // Point to a server that refuses connections cfgMutex.Lock() origHost := cfg.Server.HostGraphQL cfg.Server.HostGraphQL = "http://127.0.0.1:1" // port 1 always refused cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Server.HostGraphQL = origHost cfgMutex.Unlock() }() app := fiber.New() app.Get("/health", healthCheck) req := httptest.NewRequest("GET", "/health?check_redis=false", nil) resp, err := app.Test(req, fiber.TestConfig{Timeout: 15000 * time.Millisecond}) if err != nil { t.Fatalf("app.Test: %v", err) } defer func() { _ = resp.Body.Close() }() // Should return 503 when backend is unreachable if resp.StatusCode != fiber.StatusServiceUnavailable { t.Fatalf("want 503, got %d", resp.StatusCode) } var body map[string]any if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if body["status"] != "unhealthy" { t.Errorf("want unhealthy, got %v", body["status"]) } } // --------------------------------------------------------------------------- // processGraphQLRequest // --------------------------------------------------------------------------- func TestProcessGraphQLRequest_ValidBodyProxiesToBackend(t *testing.T) { parseConfig() _ = StartMonitoringServer() backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) _, _ = w.Write([]byte(`{"data":{"test":"ok"}}`)) })) defer backend.Close() cfgMutex.Lock() origHost := cfg.Server.HostGraphQL origHostRO := cfg.Server.HostGraphQLReadOnly origCache := cfg.Cache.CacheEnable cfg.Server.HostGraphQL = backend.URL cfg.Server.HostGraphQLReadOnly = backend.URL cfg.Cache.CacheEnable = false cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Server.HostGraphQL = origHost cfg.Server.HostGraphQLReadOnly = origHostRO cfg.Cache.CacheEnable = origCache cfgMutex.Unlock() }() app := fiber.New() app.Post("/*", processGraphQLRequest) body := `{"query":"query { __typename }"}` req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond}) if err != nil { t.Fatalf("app.Test: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { t.Errorf("want 200, got %d", resp.StatusCode) } } func TestProcessGraphQLRequest_MalformedBodyStillHandled(t *testing.T) { parseConfig() _ = StartMonitoringServer() // Backend that always returns 200 (malformed body is handled by proxy layer) backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) _, _ = w.Write([]byte(`{"errors":[{"message":"parse error"}]}`)) })) defer backend.Close() cfgMutex.Lock() origHost := cfg.Server.HostGraphQL origHostRO := cfg.Server.HostGraphQLReadOnly origCache := cfg.Cache.CacheEnable cfg.Server.HostGraphQL = backend.URL cfg.Server.HostGraphQLReadOnly = backend.URL cfg.Cache.CacheEnable = false cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Server.HostGraphQL = origHost cfg.Server.HostGraphQLReadOnly = origHostRO cfg.Cache.CacheEnable = origCache cfgMutex.Unlock() }() app := fiber.New() app.Post("/*", processGraphQLRequest) // Not valid JSON — proxy should still forward or return gracefully body := `not-json-at-all` req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond}) if err != nil { t.Fatalf("app.Test: %v", err) } defer func() { _ = resp.Body.Close() }() // Should not panic; any 2xx or 5xx is acceptable — just must not crash if resp.StatusCode < 100 || resp.StatusCode > 599 { t.Errorf("unexpected status %d", resp.StatusCode) } } // --------------------------------------------------------------------------- // handleCaching — wasCached=true path (cache hit) // --------------------------------------------------------------------------- func TestHandleCaching_CacheHitReturnsStoredResponse(t *testing.T) { parseConfig() _ = StartMonitoringServer() // Enable in-memory cache libpack_cache.EnableCache(&libpack_cache.CacheConfig{ Logger: cfg.Logger, TTL: 60, }) libpack_cache.CacheClear() cfgMutex.Lock() origEnable := cfg.Cache.CacheEnable cfg.Cache.CacheEnable = true cfg.Cache.CacheTTL = 60 cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Cache.CacheEnable = origEnable cfgMutex.Unlock() }() backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) _, _ = w.Write([]byte(`{"data":{"users":[]}}`)) })) defer backend.Close() cfgMutex.Lock() origHost := cfg.Server.HostGraphQL origHostRO := cfg.Server.HostGraphQLReadOnly cfg.Server.HostGraphQL = backend.URL cfg.Server.HostGraphQLReadOnly = backend.URL cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Server.HostGraphQL = origHost cfg.Server.HostGraphQLReadOnly = origHostRO cfgMutex.Unlock() }() app := fiber.New() app.Post("/*", processGraphQLRequest) queryBody := `{"query":"query { users { id } }"}` // First request — cache miss, hits backend req1 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody)) req1.Header.Set("Content-Type", "application/json") resp1, err := app.Test(req1, fiber.TestConfig{Timeout: 10000 * time.Millisecond}) if err != nil { t.Fatalf("first request: %v", err) } _ = resp1.Body.Close() if resp1.StatusCode != 200 { t.Fatalf("first request want 200, got %d", resp1.StatusCode) } // Second identical request — should hit cache req2 := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody)) req2.Header.Set("Content-Type", "application/json") resp2, err := app.Test(req2, fiber.TestConfig{Timeout: 10000 * time.Millisecond}) if err != nil { t.Fatalf("second request: %v", err) } _ = resp2.Body.Close() if resp2.StatusCode != 200 { t.Fatalf("second request want 200, got %d", resp2.StatusCode) } if resp2.Header.Get("X-Cache-Hit") != "true" { t.Error("second request should have X-Cache-Hit: true header") } } func TestHandleCaching_CacheMissProxiesRequest(t *testing.T) { parseConfig() _ = StartMonitoringServer() libpack_cache.EnableCache(&libpack_cache.CacheConfig{ Logger: cfg.Logger, TTL: 60, }) libpack_cache.CacheClear() cfgMutex.Lock() origEnable := cfg.Cache.CacheEnable cfg.Cache.CacheEnable = true cfg.Cache.CacheTTL = 60 cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Cache.CacheEnable = origEnable cfgMutex.Unlock() }() backendCalled := 0 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { backendCalled++ w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) _, _ = fmt.Fprintf(w, `{"data":{"call":%d}}`, backendCalled) })) defer backend.Close() cfgMutex.Lock() origHost := cfg.Server.HostGraphQL origHostRO := cfg.Server.HostGraphQLReadOnly cfg.Server.HostGraphQL = backend.URL cfg.Server.HostGraphQLReadOnly = backend.URL cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Server.HostGraphQL = origHost cfg.Server.HostGraphQLReadOnly = origHostRO cfgMutex.Unlock() }() app := fiber.New() app.Post("/*", processGraphQLRequest) // Unique query so no prior cache entry queryBody := `{"query":"query { uniqueMissTest_12345 { id } }"}` req := httptest.NewRequest("POST", "/v1/graphql", strings.NewReader(queryBody)) req.Header.Set("Content-Type", "application/json") resp, err := app.Test(req, fiber.TestConfig{Timeout: 10000 * time.Millisecond}) if err != nil { t.Fatalf("app.Test: %v", err) } _ = resp.Body.Close() if resp.StatusCode != 200 { t.Errorf("want 200, got %d", resp.StatusCode) } if resp.Header.Get("X-Cache-Hit") == "true" { t.Error("first request should not be a cache hit") } if backendCalled == 0 { t.Error("backend should have been called on cache miss") } } // --------------------------------------------------------------------------- // handleCaching — direct unit test for wasCached=true branch // --------------------------------------------------------------------------- func TestHandleCaching_DirectCacheHitBranch(t *testing.T) { parseConfig() _ = StartMonitoringServer() libpack_cache.EnableCache(&libpack_cache.CacheConfig{ Logger: cfg.Logger, TTL: 60, }) libpack_cache.CacheClear() cfgMutex.Lock() origEnable := cfg.Cache.CacheEnable cfg.Cache.CacheEnable = true cfg.Cache.CacheTTL = 60 cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Cache.CacheEnable = origEnable cfgMutex.Unlock() }() app := fiber.New() var wasCachedResult bool app.Post("/test", func(c fiber.Ctx) error { parsedResult := &parseGraphQLQueryResult{ cacheTime: 60, cacheRequest: true, activeEndpoint: cfg.Server.HostGraphQL, } // Pre-populate the cache so lookup hits cacheKey := libpack_cache.CalculateHash(c, "-", "-") libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`)) var err error wasCachedResult, err = handleCaching(c, parsedResult, "-", "-") return err }) reqCtx := &fasthttp.RequestCtx{} reqCtx.Request.SetRequestURI("/test") reqCtx.Request.Header.SetMethod("POST") reqCtx.Request.Header.Set("Content-Type", "application/json") reqCtx.Request.SetBody([]byte(`{"query":"query { cachedQuery }"}`)) ctx := app.AcquireCtx(reqCtx) defer app.ReleaseCtx(ctx) parsedResult := &parseGraphQLQueryResult{ cacheTime: 60, cacheRequest: true, activeEndpoint: cfg.Server.HostGraphQL, } cacheKey := libpack_cache.CalculateHash(ctx, "-", "-") libpack_cache.CacheStore(cacheKey, []byte(`{"data":{"cached":true}}`)) wasCached, err := handleCaching(ctx, parsedResult, "-", "-") if err != nil { t.Fatalf("handleCaching returned error: %v", err) } if !wasCached { t.Error("expected wasCached=true when cache hit") } _ = wasCachedResult } func TestHandleCaching_NoCacheEnabled_ProxiesDirect(t *testing.T) { parseConfig() _ = StartMonitoringServer() backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) _, _ = w.Write([]byte(`{"data":{"noCacheTest":true}}`)) })) defer backend.Close() cfgMutex.Lock() origEnable := cfg.Cache.CacheEnable origRedis := cfg.Cache.CacheRedisEnable origHost := cfg.Server.HostGraphQL origHostRO := cfg.Server.HostGraphQLReadOnly cfg.Cache.CacheEnable = false cfg.Cache.CacheRedisEnable = false cfg.Server.HostGraphQL = backend.URL cfg.Server.HostGraphQLReadOnly = backend.URL cfgMutex.Unlock() defer func() { cfgMutex.Lock() cfg.Cache.CacheEnable = origEnable cfg.Cache.CacheRedisEnable = origRedis cfg.Server.HostGraphQL = origHost cfg.Server.HostGraphQLReadOnly = origHostRO cfgMutex.Unlock() }() app := fiber.New() reqCtx := &fasthttp.RequestCtx{} reqCtx.Request.SetRequestURI("/v1/graphql") reqCtx.Request.Header.SetMethod("POST") reqCtx.Request.Header.Set("Content-Type", "application/json") reqCtx.Request.SetBody([]byte(`{"query":"query { noCacheTest }"}`)) fCtx := app.AcquireCtx(reqCtx) defer app.ReleaseCtx(fCtx) parsedResult := &parseGraphQLQueryResult{ cacheRequest: false, cacheTime: 0, activeEndpoint: backend.URL, } wasCached, err := handleCaching(fCtx, parsedResult, "-", "-") if err != nil { t.Fatalf("handleCaching error: %v", err) } if wasCached { t.Error("expected wasCached=false when cache disabled") } } // --------------------------------------------------------------------------- // StartHTTPProxy — starts then shuts down cleanly // --------------------------------------------------------------------------- func TestStartHTTPProxy_StartsAndShutdown(t *testing.T) { parseConfig() _ = StartMonitoringServer() // Grab a free port l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen: %v", err) } port := l.Addr().(*net.TCPAddr).Port _ = l.Close() cfgMutex.Lock() origPort := cfg.Server.PortGraphQL origTimeout := cfg.Client.ClientTimeout origWS := cfg.WebSocket.Enable origAdmin := cfg.AdminDashboard.Enable cfg.Server.PortGraphQL = port cfg.Client.ClientTimeout = 5 cfg.WebSocket.Enable = false cfg.AdminDashboard.Enable = false cfgMutex.Unlock() t.Cleanup(func() { cfgMutex.Lock() cfg.Server.PortGraphQL = origPort cfg.Client.ClientTimeout = origTimeout cfg.WebSocket.Enable = origWS cfg.AdminDashboard.Enable = origAdmin cfgMutex.Unlock() }) errCh := make(chan error, 1) go func() { errCh <- StartHTTPProxy() }() // Wait for the OnListen hook to publish httpProxyApp, i.e. for fiber to // actually be serving. A bare TCP dial is not a reliable "serving" signal: // the OS accepts connections into the listener's backlog as soon as it is // bound, before fiber's Serve loop starts pulling them off, so dialling a // freshly bound listener races server startup under -race. deadline := time.Now().Add(3 * time.Second) var serving bool for time.Now().Before(deadline) { httpProxyMu.RLock() serving = httpProxyApp != nil httpProxyMu.RUnlock() if serving { break } time.Sleep(10 * time.Millisecond) } if !serving { t.Fatalf("server did not start serving on port %d within 3s", port) } // Send a health check to confirm it's serving httpResp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health?check_graphql=false&check_redis=false", port)) if err != nil { t.Fatalf("GET /health: %v", err) } _ = httpResp.Body.Close() if httpResp.StatusCode != 200 { t.Errorf("want 200, got %d", httpResp.StatusCode) } // Shut down and confirm StartHTTPProxy's server.Listen returns instead of // blocking forever, so in-flight requests get a chance to drain. shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := shutdownHTTPProxy(shutdownCtx); err != nil { t.Fatalf("shutdownHTTPProxy: %v", err) } select { case err := <-errCh: if err != nil { t.Errorf("StartHTTPProxy returned error after shutdown: %v", err) } case <-time.After(3 * time.Second): t.Fatal("StartHTTPProxy did not return after shutdownHTTPProxy") } } // Test_shutdownHTTPProxy verifies the graceful-shutdown helper: it is a no-op // when no server is running, and it stops a listening proxy server so that // server.Listen returns (enabling a clean in-flight-request drain instead of // process-exit cutoff). func (suite *Tests) Test_shutdownHTTPProxy() { // No server running: must be a safe no-op. httpProxyMu.Lock() original := httpProxyApp httpProxyApp = nil httpProxyMu.Unlock() defer func() { httpProxyMu.Lock() httpProxyApp = original httpProxyMu.Unlock() }() // Bound the context: if a clear-signal regression made the retry loop // spin forever, context.Background() would hang the whole package for // its default 10m test timeout instead of failing fast here. noopCtx, noopCancel := context.WithTimeout(context.Background(), 5*time.Second) defer noopCancel() suite.NoError(shutdownHTTPProxy(noopCtx)) // With a listening server assigned, shutdown must stop it. app := fiber.New() app.Get("/ping", func(c fiber.Ctx) error { return c.SendStatus(200) }) ln, err := net.Listen("tcp", "127.0.0.1:0") suite.Require().NoError(err) // Signal off fiber's OnListen hook rather than dialling the raw listener: // the OS accepts connections into the listener's backlog as soon as it is // bound, before fiber's Serve loop is actually running, so a bare TCP // dial races server startup and makes shutdownHTTPProxy's no-op check // (fasthttp's listener isn't set yet) flaky under -race. serving := make(chan struct{}) app.Hooks().OnListen(func(fiber.ListenData) error { close(serving) return nil }) listenDone := make(chan struct{}) go func() { defer close(listenDone) _ = app.Listener(ln) // Mirror StartHTTPProxy's contract: clear httpProxyApp once Listener // returns, so shutdownHTTPProxy's retry loop (which waits for that // clear as proof the drain actually happened) can observe completion // instead of retrying until its context expires. httpProxyMu.Lock() if httpProxyApp == app { httpProxyApp = nil } httpProxyMu.Unlock() }() select { case <-serving: case <-time.After(3 * time.Second): suite.Fail("proxy server never started serving on " + ln.Addr().String()) return } httpProxyMu.Lock() httpProxyApp = app httpProxyMu.Unlock() // Bound the context: if a clear-signal regression made the retry loop // spin forever, context.Background() would hang the whole package for // its default 10m test timeout instead of failing fast here. shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() suite.NoError(shutdownHTTPProxy(shutdownCtx)) select { case <-listenDone: // Listen returned because we called Shutdown. case <-time.After(3 * time.Second): suite.Fail("proxy server did not shut down; in-flight requests would be cut off") } } // --------------------------------------------------------------------------- // proxyErrorHandler (C4 - circuit-open must surface as 503, not the generic // 500 fiber's DefaultErrorHandler would return; *fiber.Error keeps its own // code; any other error still maps to 500) // --------------------------------------------------------------------------- func TestProxyErrorHandler_MapsErrorsToStatusCodes(t *testing.T) { tests := []struct { name string err error wantStatus int }{ { name: "circuit open sentinel yields 503", err: ErrCircuitOpen, wantStatus: fiber.StatusServiceUnavailable, }, { name: "gobreaker open state yields 503", err: gobreaker.ErrOpenState, wantStatus: fiber.StatusServiceUnavailable, }, { name: "wrapped circuit open sentinel yields 503", err: fmt.Errorf("proxy attempt failed: %w", ErrCircuitOpen), wantStatus: fiber.StatusServiceUnavailable, }, { name: "generic error yields 500", err: errors.New("boom"), wantStatus: fiber.StatusInternalServerError, }, { name: "proxy error keeps its own status code", err: NewProxyError(ErrCodeBadGateway, "bad gateway", fiber.StatusBadGateway, false), wantStatus: fiber.StatusBadGateway, }, { name: "fiber.Error keeps its own code", err: fiber.NewError(fiber.StatusTeapot, "teapot"), wantStatus: fiber.StatusTeapot, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { app := fiber.New(fiber.Config{ErrorHandler: proxyErrorHandler}) app.Get("/err", func(c fiber.Ctx) error { return tt.err }) req := httptest.NewRequest("GET", "/err", nil) resp, err := app.Test(req, fiber.TestConfig{Timeout: 5000 * time.Millisecond}) if err != nil { t.Fatalf("app.Test: %v", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != tt.wantStatus { t.Errorf("want status %d, got %d", tt.wantStatus, resp.StatusCode) } }) } } // TestStartHTTPProxy_ErrorHandlerConfigured is a narrow regression check that // StartHTTPProxy actually wires proxyErrorHandler into the fiber app (the // bug this fixes was a missing ErrorHandler in that Config), rather than // only testing proxyErrorHandler in isolation above. func TestStartHTTPProxy_ErrorHandlerConfigured(t *testing.T) { parseConfig() _ = StartMonitoringServer() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen: %v", err) } port := l.Addr().(*net.TCPAddr).Port _ = l.Close() cfgMutex.Lock() origPort := cfg.Server.PortGraphQL origTimeout := cfg.Client.ClientTimeout origRetryBase := cfg.Client.RetryBaseDelayMs origRetryMax := cfg.Client.RetryMaxDelayMs origWS := cfg.WebSocket.Enable origAdmin := cfg.AdminDashboard.Enable origHost := cfg.Server.HostGraphQL origHostRO := cfg.Server.HostGraphQLReadOnly cfg.Server.PortGraphQL = port cfg.Client.ClientTimeout = 5 // Use short retry delays so the 7-attempt retry loop for the unreachable // backend completes in milliseconds instead of ~25 seconds. cfg.Client.RetryBaseDelayMs = 10 cfg.Client.RetryMaxDelayMs = 10 cfg.WebSocket.Enable = false cfg.AdminDashboard.Enable = false // Point at a host that always refuses connections so proxyTheRequest // fails and processGraphQLRequest's proxy-error path (mapped via // StatusCodeForError, not a bare 500) gets exercised. cfg.Server.HostGraphQL = "http://127.0.0.1:1" cfg.Server.HostGraphQLReadOnly = "http://127.0.0.1:1" cfgMutex.Unlock() t.Cleanup(func() { cfgMutex.Lock() cfg.Server.PortGraphQL = origPort cfg.Client.ClientTimeout = origTimeout cfg.Client.RetryBaseDelayMs = origRetryBase cfg.Client.RetryMaxDelayMs = origRetryMax cfg.WebSocket.Enable = origWS cfg.AdminDashboard.Enable = origAdmin cfg.Server.HostGraphQL = origHost cfg.Server.HostGraphQLReadOnly = origHostRO cfgMutex.Unlock() }) errCh := make(chan error, 1) go func() { errCh <- StartHTTPProxy() }() deadline := time.Now().Add(3 * time.Second) var serving bool for time.Now().Before(deadline) { httpProxyMu.RLock() serving = httpProxyApp != nil httpProxyMu.RUnlock() if serving { break } time.Sleep(10 * time.Millisecond) } if !serving { t.Fatalf("server did not start serving on port %d within 3s", port) } httpResp, err := http.Post( fmt.Sprintf("http://127.0.0.1:%d/v1/graphql", port), "application/json", strings.NewReader(`{"query":"query { __typename }"}`), ) if err != nil { t.Fatalf("POST /v1/graphql: %v", err) } _ = httpResp.Body.Close() // An unreachable backend is a generic proxy failure, not circuit-open, so // this must stay 500 - proving the ErrorHandler wiring did not change the // status for genuinely-500 errors. if httpResp.StatusCode != fiber.StatusInternalServerError { t.Errorf("want %d for unreachable backend, got %d", fiber.StatusInternalServerError, httpResp.StatusCode) } shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := shutdownHTTPProxy(shutdownCtx); err != nil { t.Fatalf("shutdownHTTPProxy: %v", err) } select { case err := <-errCh: if err != nil { t.Errorf("StartHTTPProxy returned error after shutdown: %v", err) } case <-time.After(3 * time.Second): t.Fatal("StartHTTPProxy did not return after shutdownHTTPProxy") } }