// Package test provides configuration utilities for test infrastructure. // This file centralizes timeout configuration for CI/CD stability. package test import ( "fmt" "log/slog" "net/http" "os" "time" ) // GetHTTPTimeout returns the HTTP client timeout for API calls. // Default is 30 seconds, configurable via E2E_HTTP_TIMEOUT environment variable. // In CI environments, this timeout ensures that stalled HTTP calls don't block // indefinitely, which was a root cause of test hangs. func GetHTTPTimeout() time.Duration { if t := os.Getenv("E2E_HTTP_TIMEOUT"); t != "" { if d, err := time.ParseDuration(t); err == nil { return d } } return 30 * time.Second } // GetPollingTimeout returns the timeout for polling operations like waiting // for client status or mock endpoint verification. // Default is 60 seconds locally, 180 seconds in CI (detected via CI or // BITBUCKET_PIPELINE_UUID environment variables). // Configurable via E2E_WAIT_TIMEOUT environment variable. func GetPollingTimeout() time.Duration { if t := os.Getenv("E2E_WAIT_TIMEOUT"); t != "" { if d, err := time.ParseDuration(t); err == nil { return d } } if isCI() { return 180 * time.Second // 3 minutes in CI } return 60 * time.Second // 1 minute locally } // GetPollInterval returns the interval between polling attempts. // Default is 500ms, configurable via E2E_POLL_INTERVAL environment variable. func GetPollInterval() time.Duration { if t := os.Getenv("E2E_POLL_INTERVAL"); t != "" { if d, err := time.ParseDuration(t); err == nil { return d } } return 500 * time.Millisecond } // GetHTTPClient returns an HTTP client with the configured timeout. // This should be used instead of &http.Client{} to ensure all HTTP operations // have bounded timeouts and won't hang indefinitely. func GetHTTPClient() *http.Client { return &http.Client{ Timeout: GetHTTPTimeout(), } } // isCI returns true if running in a CI environment. // Detects CI via the CI or BITBUCKET_PIPELINE_UUID environment variables. func isCI() bool { return os.Getenv("CI") != "" || os.Getenv("BITBUCKET_PIPELINE_UUID") != "" } // WaitForExternalReadiness waits for a service to be reachable from outside Docker. // This addresses the race condition where a container's internal health check passes // but the external port mapping isn't yet ready. This is particularly important in // Docker-in-Docker (DinD) environments like Bitbucket Pipelines. // // Parameters: // - uri: The base URI of the service (e.g., "http://localhost:32768") // - healthPath: The health check endpoint path (e.g., "/health" or "/liveness/probe") // - timeout: Maximum time to wait for the service to become reachable // // Returns an error if the service is not reachable within the timeout period. func WaitForExternalReadiness(uri string, healthPath string, timeout time.Duration) error { client := &http.Client{Timeout: 2 * time.Second} deadline := time.Now().Add(timeout) fullURL := fmt.Sprintf("%s%s", uri, healthPath) slog.Info("Waiting for external readiness", "url", fullURL, "timeout", timeout) var lastErr error attempts := 0 for time.Now().Before(deadline) { attempts++ resp, err := client.Get(fullURL) if err == nil && resp.StatusCode == http.StatusOK { resp.Body.Close() slog.Info("Service is externally reachable", "url", fullURL, "attempts", attempts) return nil } if err != nil { lastErr = err } else { lastErr = fmt.Errorf("unexpected status code: %d", resp.StatusCode) resp.Body.Close() } time.Sleep(500 * time.Millisecond) } return fmt.Errorf("service not reachable from test client after %v (%d attempts): %w", timeout, attempts, lastErr) } // WaitForAPIRoutes waits for API routes to be fully registered. // This addresses a race condition where the health endpoint returns 200 but other // API routes haven't been registered yet, causing "404 page not found" errors. // This is particularly important in slow CI environments where route registration // may take longer after the health endpoint becomes available. // // Parameters: // - uri: The base URI of the service (e.g., "http://localhost:32768") // - timeout: Maximum time to wait for routes to be registered // // Returns an error if routes are not available within the timeout period. func WaitForAPIRoutes(uri string, timeout time.Duration) error { client := &http.Client{Timeout: 2 * time.Second} deadline := time.Now().Add(timeout) // Check /clients endpoint - should return a response when routes are ready // A "404 page not found" means routes aren't registered yet // Note: /query endpoint was removed as part of query functionality removal routeURL := fmt.Sprintf("%s/clients", uri) slog.Info("Waiting for API routes to be registered", "url", routeURL, "timeout", timeout) var lastErr error attempts := 0 for time.Now().Before(deadline) { attempts++ resp, err := client.Get(routeURL) if err == nil { // Any response other than connection error means routes are registered // 200 = success, 401 = auth required (but route exists), etc. // Only "404 page not found" indicates routes not yet registered if resp.StatusCode != http.StatusNotFound { resp.Body.Close() slog.Info("API routes are registered", "url", routeURL, "attempts", attempts, "status", resp.StatusCode) return nil } lastErr = fmt.Errorf("routes not registered yet: %d", resp.StatusCode) resp.Body.Close() } else { lastErr = err } time.Sleep(500 * time.Millisecond) } return fmt.Errorf("API routes not registered after %v (%d attempts): %w", timeout, attempts, lastErr) }