""" Integration test for base path deployment using Docker Compose. This test validates that Hindsight works correctly when deployed behind a reverse proxy with path-based routing using the actual Docker Compose examples from docker/compose-examples/. Tests: 1. API with base path (direct, no proxy) 2. Full stack via docker-compose with Nginx reverse proxy 3. Regression: API without base path still works Requirements: - Docker and docker-compose installed - No nginx required on host! """ import asyncio import os import subprocess import sys import time from pathlib import Path import httpx import pytest # Paths REPO_ROOT = Path(__file__).parent.parent.parent API_PATH = REPO_ROOT / "hindsight-api-slim" COMPOSE_EXAMPLES_PATH = REPO_ROOT / "docker" / "docker-compose" / "nginx" # Add hindsight-api to path for direct API testing sys.path.insert(0, str(API_PATH)) def run_command(cmd: list[str], cwd: str | Path | None = None, env: dict | None = None) -> subprocess.CompletedProcess: """Run a command and return the result.""" return subprocess.run( cmd, cwd=cwd, env=env, capture_output=True, text=True, ) def check_docker_available() -> bool: """Check if Docker is available.""" result = run_command(["docker", "info"]) return result.returncode == 0 def get_docker_compose_command() -> list[str]: """Get the docker-compose command (modern or legacy).""" import shutil # Try modern docker compose plugin first if shutil.which("docker"): result = run_command(["docker", "compose", "version"]) if result.returncode == 0: return ["docker", "compose"] # Fall back to legacy docker-compose if shutil.which("docker-compose"): return ["docker-compose"] raise RuntimeError("docker-compose not available") def check_docker_compose_available() -> bool: """Check if docker-compose is available.""" try: get_docker_compose_command() return True except RuntimeError: return False class APIServer: """Helper to manage API server lifecycle for direct testing.""" def __init__(self, base_path: str | None = None, port: int = 18888): self.base_path = base_path self.port = port self.process = None self.env = os.environ.copy() if base_path: self.env["HINDSIGHT_API_BASE_PATH"] = base_path def start(self): """Start the API server.""" cmd = [ "uv", "run", "--directory", str(API_PATH), "hindsight-api", "--host", "0.0.0.0", "--port", str(self.port), ] log_file = f"/tmp/hindsight-api-{self.port}.log" self.log_file = open(log_file, "w") self.process = subprocess.Popen( cmd, env=self.env, stdout=self.log_file, stderr=subprocess.STDOUT ) # Wait for server to be ready base_url = f"http://localhost:{self.port}" if self.base_path: health_url = f"{base_url}{self.base_path}/health" else: health_url = f"{base_url}/health" for _ in range(60): # 60 second timeout try: response = httpx.get(health_url, timeout=2.0) if response.status_code == 200: return except (httpx.ConnectError, httpx.ReadTimeout): pass time.sleep(1) # Failed to start self.log_file.flush() with open(log_file) as f: print(f"API server failed to start. Logs:\n{f.read()}") raise RuntimeError(f"API server failed to start on port {self.port}") def stop(self): """Stop the API server.""" if self.process: self.process.terminate() try: self.process.wait(timeout=10) except subprocess.TimeoutExpired: self.process.kill() self.process.wait() self.process = None if hasattr(self, "log_file") and self.log_file: self.log_file.close() def __enter__(self): self.start() return self def __exit__(self, exc_type, exc_val, exc_tb): self.stop() class DockerComposeStack: """Helper to manage docker-compose stack lifecycle.""" def __init__(self, compose_file: Path, project_name: str = "hindsight-test"): self.compose_file = compose_file self.project_name = project_name self.compose_cmd = get_docker_compose_command() self.env = os.environ.copy() # Set required env vars for docker-compose self.env["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY", "test-key") self.env["HINDSIGHT_API_LLM_PROVIDER"] = os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "mock") self.env["HINDSIGHT_API_LLM_MODEL"] = os.environ.get("HINDSIGHT_API_LLM_MODEL", "mock-model") def start(self, timeout: int = 120): """Start the docker-compose stack.""" print(f"Starting docker-compose stack: {self.compose_file.name}") # Pull images first (but don't fail if it doesn't work) run_command( self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "pull"], cwd=self.compose_file.parent, env=self.env, ) # Start services result = run_command( self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "up", "-d", "--build"], cwd=self.compose_file.parent, env=self.env, ) if result.returncode != 0: print(f"Failed to start docker-compose:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}") raise RuntimeError("Failed to start docker-compose stack") # Wait for services to be healthy start_time = time.time() while time.time() - start_time < timeout: result = run_command( self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "ps", "--format", "json"], cwd=self.compose_file.parent, env=self.env, ) if result.returncode == 0: # Give it a few more seconds to fully initialize time.sleep(5) return time.sleep(2) # Timeout - show logs and fail self.show_logs() raise RuntimeError(f"Docker compose stack failed to start within {timeout}s") def show_logs(self): """Show docker-compose logs.""" result = run_command( self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "logs", "--tail=100"], cwd=self.compose_file.parent, env=self.env, ) print(f"Docker compose logs:\n{result.stdout}\n{result.stderr}") def stop(self): """Stop and remove the docker-compose stack.""" print(f"Stopping docker-compose stack: {self.compose_file.name}") result = run_command( self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "down", "-v"], cwd=self.compose_file.parent, env=self.env, ) if result.returncode != 0: print(f"Warning: Failed to stop docker-compose:\n{result.stderr}") def __enter__(self): self.start() return self def __exit__(self, exc_type, exc_val, exc_tb): self.stop() def test_api_without_base_path(): """Regression test: API works at root path (default behavior).""" with APIServer(base_path=None, port=18888) as server: base_url = f"http://localhost:{server.port}" # Health check response = httpx.get(f"{base_url}/health") assert response.status_code == 200 assert "status" in response.json() # API endpoints response = httpx.get(f"{base_url}/v1/default/banks") assert response.status_code == 200 assert "banks" in response.json() # OpenAPI docs response = httpx.get(f"{base_url}/docs") assert response.status_code == 200 def test_api_with_base_path_direct(): """Test API with base path configuration (direct, no proxy).""" base_path = "/hindsight" with APIServer(base_path=base_path, port=18889) as server: base_url = f"http://localhost:{server.port}" # Base path SHOULD work response = httpx.get(f"{base_url}{base_path}/health") assert response.status_code == 200 assert "status" in response.json() # API endpoints with base path response = httpx.get(f"{base_url}{base_path}/v1/default/banks") assert response.status_code == 200 assert "banks" in response.json() # OpenAPI docs with base path response = httpx.get(f"{base_url}{base_path}/docs") assert response.status_code == 200 # OpenAPI schema should have correct server URL response = httpx.get(f"{base_url}{base_path}/openapi.json") assert response.status_code == 200 openapi = response.json() assert "servers" in openapi assert openapi["servers"][0]["url"] == base_path @pytest.mark.skipif( not check_docker_compose_available(), reason="docker-compose not available" ) def test_reverse_proxy_simple_config(): """ Test reverse proxy deployment using docker-compose with Nginx. This creates a minimal test setup with: - API server running on HOST via uv (not Docker - faster, no image build needed!) - Nginx container that proxies to the host API This tests the actual reverse proxy scenario without requiring the heavy Hindsight Docker image. """ base_path = "/hindsight" api_port = 18890 # Start API on host with base path with APIServer(base_path=base_path, port=api_port): # Create test docker-compose file (nginx only) test_compose = COMPOSE_EXAMPLES_PATH / "test-reverse-proxy.yml" # Determine host address for nginx to reach host machine # host.docker.internal works on Docker Desktop (Mac/Windows) # On Linux, we use host network mode import platform if platform.system() == "Linux": network_mode = "host" api_host = "localhost" nginx_port = 18080 # With host mode, nginx must listen on 18080 directly port_mapping = "" # No port mapping with host mode else: network_mode = "bridge" api_host = "host.docker.internal" nginx_port = 80 # With bridge mode, nginx listens on 80 and is mapped port_mapping = """ ports: - "18080:80" """ compose_content = f"""version: '3.8' services: nginx: image: nginx:alpine {port_mapping} volumes: - ./test-nginx.conf:/etc/nginx/nginx.conf:ro network_mode: {network_mode} """ # Create test nginx config nginx_config = f"""events {{ worker_connections 1024; }} http {{ server {{ listen {nginx_port}; server_name localhost; location {base_path}/ {{ proxy_pass http://{api_host}:{api_port}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }} }} }} """ # Write test files test_compose.write_text(compose_content) test_nginx_conf = COMPOSE_EXAMPLES_PATH / "test-nginx.conf" test_nginx_conf.write_text(nginx_config) try: # Start nginx via docker-compose with DockerComposeStack(test_compose, project_name="hindsight-base-path-test"): proxy_url = "http://localhost:18080" # Give nginx a moment to start time.sleep(2) # Test through nginx proxy response = httpx.get(f"{proxy_url}{base_path}/health", timeout=10.0) assert response.status_code == 200 assert "status" in response.json() # API endpoints through proxy response = httpx.get(f"{proxy_url}{base_path}/v1/default/banks", timeout=10.0) assert response.status_code == 200 assert "banks" in response.json() # OpenAPI docs through proxy response = httpx.get(f"{proxy_url}{base_path}/docs", timeout=10.0) assert response.status_code == 200 finally: # Cleanup test files test_compose.unlink(missing_ok=True) test_nginx_conf.unlink(missing_ok=True) @pytest.mark.asyncio async def test_full_workflow_with_base_path(): """Test full retain/recall workflow through base path.""" base_path = "/hindsight" bank_id = "integration_test_bank" with APIServer(base_path=base_path, port=18891) as server: base_url = f"http://localhost:{server.port}{base_path}" async with httpx.AsyncClient(base_url=base_url, timeout=30.0) as client: # 1. Get bank profile (creates if needed) response = await client.get(f"/v1/default/banks/{bank_id}/profile") assert response.status_code == 200 # 2. Store a memory response = await client.post( f"/v1/default/banks/{bank_id}/memories", json={ "items": [ { "content": "Hindsight supports deployment under custom base paths for reverse proxy scenarios.", "context": "integration test" } ] }, ) assert response.status_code == 200 result = response.json() assert result["success"] is True # 3. Recall the memory response = await client.post( f"/v1/default/banks/{bank_id}/memories/recall", json={"query": "base path deployment"} ) assert response.status_code == 200 recall_result = response.json() assert "results" in recall_result # Should find our memory assert len(recall_result["results"]) > 0 if __name__ == "__main__": """Run tests directly with python.""" import sys print("=" * 70) print("Hindsight Base Path Integration Tests") print("=" * 70) print() all_passed = True # Test 1: Without base path print("Test 1: API without base path (regression test)") print("-" * 70) try: test_api_without_base_path() print("✅ PASSED\n") except Exception as e: print(f"❌ FAILED: {e}\n") all_passed = False # Test 2: With base path (direct) print("Test 2: API with base path (direct, no proxy)") print("-" * 70) try: test_api_with_base_path_direct() print("✅ PASSED\n") except Exception as e: print(f"❌ FAILED: {e}\n") all_passed = False # Test 3: Docker compose reverse proxy print("Test 3: Reverse proxy via docker-compose") print("-" * 70) if check_docker_available() and check_docker_compose_available(): try: test_reverse_proxy_simple_config() print("✅ PASSED\n") except Exception as e: print(f"❌ FAILED: {e}\n") import traceback traceback.print_exc() all_passed = False else: print("⚠️ SKIPPED: Docker or docker-compose not available\n") # Test 4: Full workflow print("Test 4: Full retain/recall workflow with base path") print("-" * 70) try: asyncio.run(test_full_workflow_with_base_path()) print("✅ PASSED\n") except Exception as e: print(f"❌ FAILED: {e}\n") all_passed = False # Summary print("=" * 70) if all_passed: print("✅ All tests passed!") sys.exit(0) else: print("❌ Some tests failed") sys.exit(1)