diff --git a/Dockerfile b/Dockerfile index aeeca61..0509829 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,10 @@ FROM python:3.13-slim WORKDIR /app -COPY backend/pyproject.toml backend/requirements.txt ./ -RUN pip install --no-cache-dir -e ".[dev]" - COPY backend/src ./src +COPY backend/pyproject.toml ./ + +RUN pip install --no-cache-dir . EXPOSE 8000 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b316f62..cc55492 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ dev = [ "mypy>=1.16.1", "pytest>=8.3.5", + "pytest-asyncio>=0.24", "ruff>=0.11.2", ] diff --git a/backend/src/main.py b/backend/src/main.py index c0169dc..2508284 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -96,8 +96,12 @@ def _release_session_lock(session_id: str) -> None: _session_locks.pop(session_id, None) logger.info("Initializing SupportAgent...") -agent: Any = SupportAgent() -logger.info("SupportAgent initialized successfully.") +try: + agent: Any = SupportAgent() + logger.info("SupportAgent initialized successfully.") +except Exception as e: + logger.warning("SupportAgent init failed (external services unavailable): %s", e) + agent = None # Startup security check env = os.getenv("ENV", "development").lower() @@ -200,6 +204,8 @@ async def solve_ticket( user: dict = Depends(get_current_user), x_session_id: str | None = Header(default=None, alias="X-Session-Id"), ): + if agent is None: + raise HTTPException(status_code=503, detail="Agent not initialized — external services unavailable") try: # Running our LangGraph State Machine logger.info("Solving ticket", extra={"query": request.user_query}) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index e5d9c50..e15fe42 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,3 +1,4 @@ +from unittest.mock import patch from middleware.auth import get_current_user @@ -8,13 +9,14 @@ def test_solve_ticket_off_topic(): app.dependency_overrides[get_current_user] = lambda: {"sub": "anonymous", "permissions": []} client = TestClient(app) - response = client.post("/v1/solve-ticket", json={ - "user_query": "How to bake a cake?", - "session_id": "test-1" - }) - assert response.status_code == 200 - body = response.json() - assert body["status"] == "needs_ingestion" + with patch("main.check_rate_limit", return_value=True): + response = client.post("/v1/solve-ticket", json={ + "user_query": "How to bake a cake?", + "session_id": "test-1" + }) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "needs_ingestion" def test_session_id_presence(): @@ -24,8 +26,9 @@ def test_session_id_presence(): app.dependency_overrides[get_current_user] = lambda: {"sub": "anonymous", "permissions": []} client = TestClient(app) - response = client.post("/v1/solve-ticket", json={ - "user_query": "What is this repository about?", - "session_id": "unique-id-999" - }) - assert response.json()["metadata"]["session_id"] == "unique-id-999" + with patch("main.check_rate_limit", return_value=True): + response = client.post("/v1/solve-ticket", json={ + "user_query": "What is this repository about?", + "session_id": "unique-id-999" + }) + assert response.json()["metadata"]["session_id"] == "unique-id-999" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 4022ea2..5d7d9d9 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -57,23 +57,31 @@ def test_auth_error_exception(): def test_jwks_network_error_returns_401(): from jwt import PyJWKClientError - with patch("middleware.auth.PyJWKClient") as mock_client_cls: - mock_client = MagicMock() - mock_client_cls.return_value = mock_client - mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Connection refused") - from middleware.auth import decode_jwt - with pytest.raises(AuthError) as excinfo: - decode_jwt("some.token.here") - assert excinfo.value.status_code == 401 + with patch("middleware.auth.settings") as mock_settings: + mock_settings.AUTH_ENABLED = True + mock_settings.AUTH0_DOMAIN = "test.auth0.com" + mock_settings.AUTH0_AUDIENCE = "https://api.test.com" + with patch("middleware.auth.PyJWKClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Connection refused") + from middleware.auth import decode_jwt + with pytest.raises(AuthError) as excinfo: + decode_jwt("some.token.here") + assert excinfo.value.status_code == 401 def test_jwks_timeout_error_returns_401(): from jwt import PyJWKClientError - with patch("middleware.auth.PyJWKClient") as mock_client_cls: - mock_client = MagicMock() - mock_client_cls.return_value = mock_client - mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Timeout") - from middleware.auth import decode_jwt - with pytest.raises(AuthError) as excinfo: - decode_jwt("some.token.here") - assert excinfo.value.status_code == 401 + with patch("middleware.auth.settings") as mock_settings: + mock_settings.AUTH_ENABLED = True + mock_settings.AUTH0_DOMAIN = "test.auth0.com" + mock_settings.AUTH0_AUDIENCE = "https://api.test.com" + with patch("middleware.auth.PyJWKClient") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.get_signing_key_from_jwt.side_effect = PyJWKClientError("Timeout") + from middleware.auth import decode_jwt + with pytest.raises(AuthError) as excinfo: + decode_jwt("some.token.here") + assert excinfo.value.status_code == 401 diff --git a/backend/tests/test_docs_loader.py b/backend/tests/test_docs_loader.py index 05708df..f77f02a 100644 --- a/backend/tests/test_docs_loader.py +++ b/backend/tests/test_docs_loader.py @@ -4,13 +4,13 @@ class TestFileLimitEnforcement: def test_file_limit_respected(self): - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader DocsLoader() files = [(f"path/to/doc{i}.md", f"doc{i}.md") for i in range(settings.REPO_FILE_LIMIT)] assert len(files) <= settings.REPO_FILE_LIMIT def test_file_limit_exceeded_raises(self): - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader class TestLoader(DocsLoader): def load_and_split(self): @@ -32,7 +32,7 @@ def load_and_split(self): class TestLocalModeBranching: def test_local_mode_true_calls_clone_path(self, monkeypatch): monkeypatch.setattr(settings, "LOCAL_MODE", True) - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader(repo_url="owner/repo") # Mock prepare_local_repo to avoid network calls @@ -45,7 +45,7 @@ def test_local_mode_true_calls_clone_path(self, monkeypatch): def test_local_mode_false_uses_api(self, monkeypatch): monkeypatch.setattr(settings, "LOCAL_MODE", False) - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader(repo_url="owner/repo") # Mock fetch_via_api to avoid network @@ -56,24 +56,24 @@ def test_local_mode_false_uses_api(self, monkeypatch): class TestFetchViaApi: def test_repo_owner_name_from_url(self): - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader(repo_url="https://github.com/owner/repo.git") assert loader._repo_owner_name() == "owner/repo" def test_repo_owner_name_from_short(self): - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader(repo_url="owner/repo") assert loader._repo_owner_name() == "owner/repo" def test_repo_owner_name_empty_fallback(self): - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader() # Falls back to settings.TARGET_REPO which might be None result = loader._repo_owner_name() assert result is not None or result == "" def test_repo_owner_name_from_nested_url(self): - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader(repo_url="https://github.com/org/team/repo.git") assert loader._repo_owner_name() == "team/repo" @@ -81,12 +81,12 @@ def test_repo_owner_name_from_nested_url(self): class TestDocsLoaderInit: def test_github_token_from_settings(self, monkeypatch): monkeypatch.setattr(settings, "GITHUB_TOKEN", "ghp_test_token") - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader(repo_url="owner/repo") assert loader.github_token == "ghp_test_token" def test_github_token_from_param_overrides_settings(self, monkeypatch): monkeypatch.setattr(settings, "GITHUB_TOKEN", "ghp_default") - from src.ingestion.docs_loader import DocsLoader + from ingestion.docs_loader import DocsLoader loader = DocsLoader(repo_url="owner/repo", github_token="ghp_explicit") assert loader.github_token == "ghp_explicit" diff --git a/backend/tests/test_rate_limit_auth.py b/backend/tests/test_rate_limit_auth.py index 3b8d3d3..cce3ac6 100644 --- a/backend/tests/test_rate_limit_auth.py +++ b/backend/tests/test_rate_limit_auth.py @@ -3,12 +3,12 @@ def test_solve_ticket_uses_user_sub_for_rate_limit(): """Verify solve_ticket passes user sub to check_rate_limit.""" - from src.main import app + from main import app from middleware.auth import get_current_user app.dependency_overrides[get_current_user] = lambda: {"sub": "auth0|user123", "permissions": []} - with patch("src.main.check_rate_limit") as mock_check: + with patch("main.check_rate_limit") as mock_check: mock_check.return_value = True from fastapi.testclient import TestClient client = TestClient(app) @@ -24,12 +24,12 @@ def test_solve_ticket_uses_user_sub_for_rate_limit(): def test_solve_ticket_uses_anonymous_when_no_sub(): """When auth returns no 'sub', fallback to 'anonymous'.""" - from src.main import app + from main import app from middleware.auth import get_current_user app.dependency_overrides[get_current_user] = lambda: {"permissions": []} - with patch("src.main.check_rate_limit") as mock_check: + with patch("main.check_rate_limit") as mock_check: mock_check.return_value = True from fastapi.testclient import TestClient client = TestClient(app) diff --git a/backend/tests/test_webhook_auth.py b/backend/tests/test_webhook_auth.py index 70d7541..9d7642b 100644 --- a/backend/tests/test_webhook_auth.py +++ b/backend/tests/test_webhook_auth.py @@ -5,12 +5,12 @@ def test_webhook_refuses_when_secret_not_configured_in_production(): """When WEBHOOK_SECRET is not set and ENV=production, return 503.""" - from src.main import app + from main import app client = TestClient(app) # Mock settings to have no WEBHOOK_SECRET - with patch('src.main.settings') as mock_settings: + with patch('main.settings') as mock_settings: mock_settings.WEBHOOK_SECRET = "" mock_settings.WEBHOOK_SESSION_ID = "" @@ -27,11 +27,11 @@ def test_webhook_refuses_when_secret_not_configured_in_production(): def test_webhook_allows_when_auth_disabled_in_development(): """When WEBHOOK_SECRET is not set, ENV=development, and WEBHOOK_AUTH_DISABLED=true, allow.""" - from src.main import app + from main import app client = TestClient(app) - with patch('src.main.settings') as mock_settings: + with patch('main.settings') as mock_settings: mock_settings.WEBHOOK_SECRET = "" mock_settings.WEBHOOK_SESSION_ID = "" @@ -48,11 +48,11 @@ def test_webhook_allows_when_auth_disabled_in_development(): def test_webhook_refuses_when_auth_disabled_not_true(): """When WEBHOOK_SECRET is not set, ENV=development, and WEBHOOK_AUTH_DISABLED not 'true', return 503.""" - from src.main import app + from main import app client = TestClient(app) - with patch('src.main.settings') as mock_settings: + with patch('main.settings') as mock_settings: mock_settings.WEBHOOK_SECRET = "" mock_settings.WEBHOOK_SESSION_ID = "" @@ -69,11 +69,11 @@ def test_webhook_refuses_when_auth_disabled_not_true(): def test_webhook_with_secret_proceeds_to_signature_verification(): """When WEBHOOK_SECRET is set, should proceed to signature verification.""" - from src.main import app + from main import app client = TestClient(app) - with patch('src.main.settings') as mock_settings: + with patch('main.settings') as mock_settings: mock_settings.WEBHOOK_SECRET = "test_secret" mock_settings.WEBHOOK_SESSION_ID = "" @@ -84,4 +84,4 @@ def test_webhook_with_secret_proceeds_to_signature_verification(): content=b"test" ) assert response.status_code == 403 - assert response.json()["detail"] == "Missing signature" \ No newline at end of file + assert response.json()["detail"] == "Missing signature"