diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7c475fea6..11ceb7a93 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,10 +1,12 @@ """Shared fixtures for integration tests.""" +import os from pathlib import Path from typing import Generator import pytest from fastapi import Request, Response +from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session @@ -157,3 +159,33 @@ async def test_auth_fixture(test_request: Request) -> AuthTuple: """ noop_auth = NoopAuthDependency() return await noop_auth(test_request) + + +@pytest.fixture(name="integration_http_client") +def integration_http_client_fixture( + test_config: object, +) -> Generator[TestClient, None, None]: + """Provide a TestClient for the app with integration config. + + Use for integration tests that need to send real HTTP requests (e.g. empty + body validation). Depends on test_config so configuration is loaded first. + """ + _ = test_config + config_path = ( + Path(__file__).resolve().parent.parent + / "configuration" + / "lightspeed-stack.yaml" + ) + assert config_path.exists(), f"Config file not found: {config_path}" + + original = os.environ.get("LIGHTSPEED_STACK_CONFIG_PATH") + os.environ["LIGHTSPEED_STACK_CONFIG_PATH"] = str(config_path) + try: + from app.main import app # pylint: disable=import-outside-toplevel + + yield TestClient(app) + finally: + if original is not None: + os.environ["LIGHTSPEED_STACK_CONFIG_PATH"] = original + else: + os.environ.pop("LIGHTSPEED_STACK_CONFIG_PATH", None) diff --git a/tests/integration/endpoints/test_query_integration.py b/tests/integration/endpoints/test_query_integration.py index 7728179ae..e9397265c 100644 --- a/tests/integration/endpoints/test_query_integration.py +++ b/tests/integration/endpoints/test_query_integration.py @@ -330,6 +330,182 @@ async def test_query_v2_endpoint_with_attachments( assert response.response is not None +@pytest.mark.asyncio +async def test_query_v2_endpoint_empty_payload( + test_config: AppConfig, + mock_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test query v2 endpoint with minimal payload (no attachments). + + Verifies that a request with only the required query and no attachments + field does not break the handler and returns 200. + """ + _ = test_config + _ = mock_llama_stack_client + + query_request = QueryRequest(query="what is kubernetes?") + + response = await query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert getattr(response, "status_code", status.HTTP_200_OK) == status.HTTP_200_OK + assert response.conversation_id is not None + assert response.response is not None + + +@pytest.mark.asyncio +async def test_query_v2_endpoint_empty_attachments_list( + test_config: AppConfig, + mock_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test query v2 endpoint accepts empty attachment list. + + Verifies that POST /v1/query with attachments=[] returns 200 and + application/json response. + """ + _ = test_config + _ = mock_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[], + ) + + response = await query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert getattr(response, "status_code", status.HTTP_200_OK) == status.HTTP_200_OK + assert response.conversation_id is not None + assert response.response is not None + + +@pytest.mark.asyncio +async def test_query_v2_endpoint_multiple_attachments( + test_config: AppConfig, + mock_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test query v2 endpoint with multiple attachments. + + Verifies that two attachments (log + configuration) are accepted + and processed. + """ + _ = test_config + _ = mock_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[ + Attachment( + attachment_type="log", + content_type="text/plain", + content="log content", + ), + Attachment( + attachment_type="configuration", + content_type="application/json", + content='{"key": "value"}', + ), + ], + ) + + response = await query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert getattr(response, "status_code", status.HTTP_200_OK) == status.HTTP_200_OK + assert response.conversation_id is not None + assert response.response is not None + + +@pytest.mark.asyncio +async def test_query_v2_endpoint_attachment_unknown_type_returns_422( + test_config: AppConfig, + mock_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test query v2 endpoint returns 422 for unknown attachment type.""" + _ = test_config + _ = mock_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[ + Attachment( + attachment_type="unknown_type", + content_type="text/plain", + content="content", + ) + ], + ) + + with pytest.raises(HTTPException) as exc_info: + await query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert isinstance(exc_info.value.detail, dict) + assert "unknown_type" in exc_info.value.detail["cause"] + assert "Invalid" in exc_info.value.detail["response"] + + +@pytest.mark.asyncio +async def test_query_v2_endpoint_attachment_unknown_content_type_returns_422( + test_config: AppConfig, + mock_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test query v2 endpoint returns 422 for unknown attachment content type.""" + _ = test_config + _ = mock_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[ + Attachment( + attachment_type="log", + content_type="unknown/type", + content="content", + ) + ], + ) + + with pytest.raises(HTTPException) as exc_info: + await query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert isinstance(exc_info.value.detail, dict) + assert "unknown/type" in exc_info.value.detail["cause"] + assert "Invalid" in exc_info.value.detail["response"] + + # ========================================== # Tool Integration Tests # ========================================== diff --git a/tests/integration/endpoints/test_streaming_query_integration.py b/tests/integration/endpoints/test_streaming_query_integration.py new file mode 100644 index 000000000..8bdd4a5b6 --- /dev/null +++ b/tests/integration/endpoints/test_streaming_query_integration.py @@ -0,0 +1,258 @@ +"""Integration tests for the /streaming_query endpoint (using Responses API).""" + +from collections.abc import AsyncIterator +from typing import Any, Generator + +import pytest +from fastapi import HTTPException, Request, status +from fastapi.responses import StreamingResponse +from fastapi.testclient import TestClient +from pytest_mock import AsyncMockType, MockerFixture + +from app.endpoints.streaming_query import streaming_query_endpoint_handler +from authentication.interface import AuthTuple +from configuration import AppConfig +from models.requests import Attachment, QueryRequest + + +@pytest.fixture(name="mock_streaming_llama_stack_client") +def mock_llama_stack_streaming_fixture( + mocker: MockerFixture, +) -> Generator[Any, None, None]: + """Mock only the Llama Stack client (holder + client). + + Configures the client so the real handler runs: models, vector_stores, + conversations, shields, vector_io, and responses.create returning a minimal + stream. No other code paths are patched. + """ + mock_holder_class = mocker.patch( + "app.endpoints.streaming_query.AsyncLlamaStackClientHolder" + ) + mock_client = mocker.AsyncMock() + + mock_model = mocker.MagicMock() + mock_model.id = "test-provider/test-model" + mock_model.custom_metadata = { + "provider_id": "test-provider", + "model_type": "llm", + } + mock_client.models.list.return_value = [mock_model] + + mock_vector_stores_response = mocker.MagicMock() + mock_vector_stores_response.data = [] + mock_client.vector_stores.list.return_value = mock_vector_stores_response + + mock_conversation = mocker.MagicMock() + mock_conversation.id = "conv_" + "a" * 48 + mock_client.conversations.create = mocker.AsyncMock(return_value=mock_conversation) + + mock_client.shields.list.return_value = [] + + mock_client.conversations.items.create = mocker.AsyncMock() + + mock_vector_io_response = mocker.MagicMock() + mock_vector_io_response.chunks = [] + mock_vector_io_response.scores = [] + mock_client.vector_io.query = mocker.AsyncMock(return_value=mock_vector_io_response) + + async def _mock_stream() -> AsyncIterator[Any]: + chunk = mocker.MagicMock() + chunk.type = "response.output_text.done" + chunk.text = "test" + yield chunk + + async def _responses_create(**kwargs: Any) -> Any: + if kwargs.get("stream", True): + return _mock_stream() + mock_resp = mocker.MagicMock() + mock_resp.output = [mocker.MagicMock(content="topic summary")] + return mock_resp + + mock_client.responses.create = mocker.AsyncMock(side_effect=_responses_create) + + mock_holder_class.return_value.get_client.return_value = mock_client + + yield mock_client + + +# ========================================== +# Attachment tests (mirror query integration) +# ========================================== + + +@pytest.mark.asyncio +async def test_streaming_query_v2_endpoint_empty_payload( + test_config: AppConfig, + mock_streaming_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test streaming_query with minimal payload (no attachments).""" + _ = test_config + _ = mock_streaming_llama_stack_client + + query_request = QueryRequest(query="what is kubernetes?") + + response = await streaming_query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response, StreamingResponse) + assert response.media_type == "text/event-stream" + + +@pytest.mark.asyncio +async def test_streaming_query_v2_endpoint_empty_attachments_list( + test_config: AppConfig, + mock_streaming_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test streaming_query accepts empty attachment list.""" + _ = test_config + _ = mock_streaming_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[], + ) + + response = await streaming_query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response, StreamingResponse) + assert response.media_type == "text/event-stream" + + +@pytest.mark.asyncio +async def test_streaming_query_v2_endpoint_multiple_attachments( + test_config: AppConfig, + mock_streaming_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test streaming_query with multiple attachments (log + configuration).""" + _ = test_config + _ = mock_streaming_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[ + Attachment( + attachment_type="log", + content_type="text/plain", + content="log content", + ), + Attachment( + attachment_type="configuration", + content_type="application/json", + content='{"key": "value"}', + ), + ], + ) + + response = await streaming_query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert response.status_code == status.HTTP_200_OK + assert isinstance(response, StreamingResponse) + assert response.media_type == "text/event-stream" + + +@pytest.mark.asyncio +async def test_streaming_query_v2_endpoint_attachment_unknown_type_returns_422( + test_config: AppConfig, + mock_streaming_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test streaming_query returns 422 for unknown attachment type.""" + _ = test_config + _ = mock_streaming_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[ + Attachment( + attachment_type="unknown_type", + content_type="text/plain", + content="content", + ) + ], + ) + + with pytest.raises(HTTPException) as exc_info: + await streaming_query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert isinstance(exc_info.value.detail, dict) + assert "unknown_type" in exc_info.value.detail["cause"] + assert "Invalid" in exc_info.value.detail["response"] + + +@pytest.mark.asyncio +async def test_streaming_query_v2_endpoint_attachment_unknown_content_type_returns_422( + test_config: AppConfig, + mock_streaming_llama_stack_client: AsyncMockType, + test_request: Request, + test_auth: AuthTuple, +) -> None: + """Test streaming_query returns 422 for unknown attachment content type.""" + _ = test_config + _ = mock_streaming_llama_stack_client + + query_request = QueryRequest( + query="what is kubernetes?", + attachments=[ + Attachment( + attachment_type="log", + content_type="unknown/type", + content="content", + ) + ], + ) + + with pytest.raises(HTTPException) as exc_info: + await streaming_query_endpoint_handler( + request=test_request, + query_request=query_request, + auth=test_auth, + mcp_headers={}, + ) + + assert exc_info.value.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert isinstance(exc_info.value.detail, dict) + assert "unknown/type" in exc_info.value.detail["cause"] + assert "Invalid" in exc_info.value.detail["response"] + + +def test_streaming_query_v2_endpoint_empty_body_returns_422( + integration_http_client: TestClient, +) -> None: + """Test streaming_query with empty request body returns 422.""" + response = integration_http_client.post( + "/v1/streaming_query", + json={}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + data = response.json() + assert "detail" in data diff --git a/tests/integration/test_openapi_json.py b/tests/integration/test_openapi_json.py index 972a57743..17ff8ac66 100644 --- a/tests/integration/test_openapi_json.py +++ b/tests/integration/test_openapi_json.py @@ -1,5 +1,6 @@ """Tests the OpenAPI specification that is to be stored in docs/openapi.json.""" +import importlib import json from pathlib import Path from typing import Any @@ -56,8 +57,10 @@ def _load_openapi_spec_from_url() -> dict[str, Any]: configuration_filename = "tests/configuration/lightspeed-stack-proper-name.yaml" cfg = configuration cfg.load_configuration(configuration_filename) - from app.main import app # pylint: disable=C0415 + import app.main as app_main # pylint: disable=C0415 + importlib.reload(app_main) + app = app_main.app client = TestClient(app) response = client.get("/openapi.json") assert response.status_code == requests.codes.ok # pylint: disable=no-member