|
1 | 1 | """ |
2 | | -Pytest configuration for Content Generation backend tests. |
| 2 | +Pytest configuration and fixtures for backend tests. |
3 | 3 |
|
4 | | -Adds content-gen/src/backend to sys.path so that imports like |
5 | | -``from services.title_service import TitleService`` resolve correctly |
6 | | -when pytest is invoked by the CI workflow from the repo root: |
7 | | -
|
8 | | - pytest ./content-gen/src/tests |
| 4 | +This module provides reusable fixtures for testing: |
| 5 | +- Mock Azure services (CosmosDB, Blob Storage, OpenAI) |
| 6 | +- Test Quart app instance |
| 7 | +- Sample test data |
9 | 8 | """ |
10 | 9 |
|
11 | | -import sys |
12 | | -import os |
13 | 10 | import asyncio |
| 11 | +import gc |
| 12 | +import os |
| 13 | +import sys |
| 14 | +from datetime import datetime, timezone |
| 15 | +from typing import AsyncGenerator |
| 16 | + |
| 17 | +import pytest |
| 18 | +from quart import Quart |
| 19 | + |
| 20 | + |
| 21 | +def pytest_configure(config): |
| 22 | + """Set minimal env vars required for backend imports before test collection. |
| 23 | +
|
| 24 | + Only sets variables absolutely required to import settings.py without errors. |
| 25 | + All other test environment configuration is handled by the mock_environment fixture. |
| 26 | + """ |
| 27 | + # AZURE_OPENAI_ENDPOINT is required by _AzureOpenAISettings validator |
| 28 | + os.environ.setdefault("AZURE_OPENAI_ENDPOINT", "https://test.openai.azure.com/") |
| 29 | + |
| 30 | + # Add the backend directory to the Python path |
| 31 | + tests_dir = os.path.dirname(os.path.abspath(__file__)) |
| 32 | + backend_dir = os.path.join(os.path.dirname(tests_dir), 'backend') |
| 33 | + if backend_dir not in sys.path: |
| 34 | + sys.path.insert(0, backend_dir) |
| 35 | + |
| 36 | + # Set Windows event loop policy (fixes pytest-asyncio auto mode compatibility) |
| 37 | + if sys.platform == "win32": |
| 38 | + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) |
| 39 | + |
| 40 | + |
| 41 | +def pytest_sessionfinish(session, exitstatus): # noqa: ARG001 |
| 42 | + """Clean up any remaining async resources after test session. |
| 43 | +
|
| 44 | + This helps prevent 'Unclosed client session' warnings from aiohttp |
| 45 | + that can occur when Azure SDK or other async clients aren't fully closed. |
| 46 | +
|
| 47 | + Args: |
| 48 | + session: pytest Session object (required by hook signature) |
| 49 | + exitstatus: exit status code (required by hook signature) |
| 50 | + """ |
| 51 | + del session, exitstatus # Unused but required by pytest hook signature |
| 52 | + # Force garbage collection to trigger cleanup of any unclosed sessions |
| 53 | + gc.collect() |
| 54 | + |
| 55 | + # Close any remaining event loops |
| 56 | + try: |
| 57 | + loop = asyncio.get_event_loop() |
| 58 | + if loop.is_running(): |
| 59 | + loop.stop() |
| 60 | + if not loop.is_closed(): |
| 61 | + loop.close() |
| 62 | + except Exception: |
| 63 | + pass |
| 64 | + |
| 65 | + |
| 66 | +@pytest.fixture(scope="function", autouse=True) |
| 67 | +def mock_environment(monkeypatch): |
| 68 | + """Set test environment variables with correct names matching settings.py. |
| 69 | +
|
| 70 | + Uses monkeypatch for proper test isolation - each test starts with a clean |
| 71 | + environment and changes are automatically reverted after the test. |
| 72 | + """ |
| 73 | + env_vars = { |
| 74 | + # Azure OpenAI (required - _AzureOpenAISettings) |
| 75 | + "AZURE_OPENAI_ENDPOINT": "https://test-openai.openai.azure.com/", |
| 76 | + "AZURE_OPENAI_API_VERSION": "2024-08-01-preview", |
14 | 77 |
|
15 | | -# ---- environment setup (BEFORE any backend imports) ----------------------- |
16 | | -# The settings module reads env-vars at import time via pydantic-settings. |
17 | | -# Set minimal dummy values so that the module can be imported in CI where |
18 | | -# no .env file or Azure resources exist. |
19 | | -os.environ.setdefault("AZURE_OPENAI_ENDPOINT", "https://test.openai.azure.com") |
20 | | -os.environ.setdefault("AZURE_OPENAI_RESOURCE", "test-resource") |
21 | | -os.environ.setdefault("AZURE_COSMOS_ENDPOINT", "https://test.documents.azure.com:443/") |
22 | | -os.environ.setdefault("AZURE_COSMOSDB_DATABASE", "test-db") |
23 | | -os.environ.setdefault("AZURE_COSMOSDB_ACCOUNT", "test-account") |
24 | | -os.environ.setdefault("AZURE_COSMOSDB_CONVERSATIONS_CONTAINER", "conversations") |
25 | | -os.environ.setdefault("DOTENV_PATH", "") # prevent reading a real .env file |
| 78 | + # Azure Cosmos DB (_CosmosSettings uses AZURE_COSMOS_ prefix) |
| 79 | + "AZURE_COSMOS_ENDPOINT": "https://test-cosmos.documents.azure.com:443/", |
| 80 | + "AZURE_COSMOS_DATABASE_NAME": "test-db", |
26 | 81 |
|
27 | | -import pytest # noqa: E402 (must come after env setup) |
| 82 | + # Chat History (_ChatHistorySettings uses AZURE_COSMOSDB_ prefix) |
| 83 | + "AZURE_COSMOSDB_DATABASE": "test-db", |
| 84 | + "AZURE_COSMOSDB_ACCOUNT": "test-cosmos", |
| 85 | + "AZURE_COSMOSDB_CONVERSATIONS_CONTAINER": "conversations", |
| 86 | + "AZURE_COSMOSDB_PRODUCTS_CONTAINER": "products", |
28 | 87 |
|
29 | | -# ---- path setup ---------------------------------------------------------- |
30 | | -# The backend package lives at <repo>/content-gen/src/backend. |
31 | | -# We add <repo>/content-gen/src/backend so that ``import settings``, |
32 | | -# ``import services.…``, ``import models``, etc. resolve correctly. |
33 | | -_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) |
34 | | -_SRC_DIR = os.path.dirname(_THIS_DIR) # content-gen/src |
35 | | -_BACKEND_DIR = os.path.join(_SRC_DIR, "backend") # content-gen/src/backend |
| 88 | + # Azure Blob Storage (_StorageSettings uses AZURE_BLOB_ prefix) |
| 89 | + "AZURE_BLOB_ACCOUNT_NAME": "teststorage", |
| 90 | + "AZURE_BLOB_PRODUCT_IMAGES_CONTAINER": "product-images", |
| 91 | + "AZURE_BLOB_GENERATED_IMAGES_CONTAINER": "generated-images", |
36 | 92 |
|
37 | | -for _p in (_SRC_DIR, _BACKEND_DIR): |
38 | | - if _p not in sys.path: |
39 | | - sys.path.insert(0, _p) |
| 93 | + # Azure AI Search (_SearchSettings uses AZURE_AI_SEARCH_ prefix) |
| 94 | + "AZURE_AI_SEARCH_ENDPOINT": "https://test-search.search.windows.net", |
| 95 | + "AZURE_AI_SEARCH_PRODUCTS_INDEX": "products", |
| 96 | + "AZURE_AI_SEARCH_IMAGE_INDEX": "product-images", |
40 | 97 |
|
| 98 | + # AI Foundry (disabled for tests) |
| 99 | + "USE_FOUNDRY": "false", |
41 | 100 |
|
42 | | -# ---- fixtures ------------------------------------------------------------- |
| 101 | + # Admin API (empty = development mode, no auth required) |
| 102 | + "ADMIN_API_KEY": "", |
| 103 | + } |
| 104 | + |
| 105 | + for key, value in env_vars.items(): |
| 106 | + monkeypatch.setenv(key, value) |
| 107 | + |
| 108 | + yield |
| 109 | + |
| 110 | + |
| 111 | +@pytest.fixture |
| 112 | +async def app() -> AsyncGenerator[Quart, None]: |
| 113 | + """Create a test Quart app instance.""" |
| 114 | + # Import here to ensure environment variables are set first |
| 115 | + from app import app as quart_app |
| 116 | + |
| 117 | + quart_app.config["TESTING"] = True |
| 118 | + |
| 119 | + yield quart_app |
43 | 120 |
|
44 | | -@pytest.fixture(scope="session") |
45 | | -def event_loop(): |
46 | | - """Create an instance of the default event loop for each test session.""" |
47 | | - loop = asyncio.get_event_loop_policy().new_event_loop() |
48 | | - yield loop |
49 | | - loop.close() |
| 121 | + |
| 122 | +@pytest.fixture |
| 123 | +async def client(app: Quart): |
| 124 | + """Create a test client for the Quart app.""" |
| 125 | + return app.test_client() |
50 | 126 |
|
51 | 127 |
|
52 | 128 | @pytest.fixture |
53 | | -def sample_creative_brief(): |
54 | | - """Sample creative brief for testing.""" |
| 129 | +def sample_product_dict(): |
| 130 | + """Sample product data as dictionary.""" |
55 | 131 | return { |
56 | | - "overview": "Summer Sale 2024 Campaign", |
57 | | - "objectives": "Increase online sales by 25% during the summer season", |
58 | | - "target_audience": "Young professionals aged 25-40 interested in premium electronics", |
59 | | - "key_message": "Experience premium quality at unbeatable summer prices", |
60 | | - "tone_and_style": "Upbeat, modern, and aspirational", |
61 | | - "deliverable": "Social media carousel posts and email banners", |
62 | | - "timelines": "Campaign runs June 1 - August 31, 2024", |
63 | | - "visual_guidelines": "Use bright summer colors, outdoor settings, lifestyle imagery", |
64 | | - "cta": "Shop Now", |
| 132 | + "id": "CP-0001", |
| 133 | + "product_name": "Snow Veil", |
| 134 | + "description": "A soft, airy white with minimal undertones", |
| 135 | + "tags": "soft white, airy, minimal, clean", |
| 136 | + "price": 45.99, |
| 137 | + "sku": "CP-0001", |
| 138 | + "image_url": "https://test.blob.core.windows.net/images/snow-veil.jpg", |
| 139 | + "category": "Paint", |
| 140 | + "created_at": datetime.now(timezone.utc).isoformat(), |
| 141 | + "updated_at": datetime.now(timezone.utc).isoformat() |
65 | 142 | } |
66 | 143 |
|
67 | 144 |
|
68 | 145 | @pytest.fixture |
69 | | -def sample_product(): |
70 | | - """Sample product for testing.""" |
| 146 | +def sample_product(sample_product_dict): |
| 147 | + """Sample product as Pydantic model.""" |
| 148 | + from models import Product |
| 149 | + return Product(**sample_product_dict) |
| 150 | + |
| 151 | + |
| 152 | +@pytest.fixture |
| 153 | +def sample_creative_brief_dict(): |
| 154 | + """Sample creative brief data as dictionary.""" |
71 | 155 | return { |
72 | | - "product_name": "ProMax Wireless Headphones", |
73 | | - "category": "Electronics", |
74 | | - "sub_category": "Audio", |
75 | | - "marketing_description": "Immerse yourself in crystal-clear sound with our flagship wireless headphones.", |
76 | | - "detailed_spec_description": "40mm custom drivers, Active Noise Cancellation, 30-hour battery life, Bluetooth 5.2, USB-C fast charging", |
77 | | - "sku": "PM-WH-001", |
78 | | - "model": "ProMax-2024", |
79 | | - "image_url": "https://example.com/images/headphones.jpg", |
80 | | - "image_description": "Sleek over-ear headphones in matte black with silver accents", |
| 156 | + "overview": "Spring campaign for eco-friendly paint line", |
| 157 | + "objectives": "Increase brand awareness and drive 20% sales growth", |
| 158 | + "target_audience": "Homeowners aged 30-50, environmentally conscious", |
| 159 | + "key_message": "Beautiful colors that care for the planet", |
| 160 | + "tone_and_style": "Warm, optimistic, trustworthy", |
| 161 | + "deliverable": "Social media posts and email campaign", |
| 162 | + "timelines": "Launch March 1, run for 6 weeks", |
| 163 | + "visual_guidelines": "Natural lighting, green spaces, happy families", |
| 164 | + "cta": "Shop Now - Free Shipping" |
81 | 165 | } |
| 166 | + |
| 167 | + |
| 168 | +@pytest.fixture |
| 169 | +def sample_creative_brief(sample_creative_brief_dict): |
| 170 | + """Sample creative brief as Pydantic model.""" |
| 171 | + from models import CreativeBrief |
| 172 | + return CreativeBrief(**sample_creative_brief_dict) |
| 173 | + |
| 174 | + |
| 175 | +@pytest.fixture |
| 176 | +def authenticated_headers(): |
| 177 | + """Headers simulating an authenticated user via EasyAuth.""" |
| 178 | + return { |
| 179 | + "X-Ms-Client-Principal-Id": "test-user-123", |
| 180 | + "X-Ms-Client-Principal-Name": "test@example.com", |
| 181 | + "X-Ms-Client-Principal-Idp": "aad" |
| 182 | + } |
| 183 | + |
| 184 | + |
| 185 | +@pytest.fixture |
| 186 | +def admin_headers(): |
| 187 | + """Headers with admin API key.""" |
| 188 | + return { |
| 189 | + "X-Admin-API-Key": "test-admin-key" |
| 190 | + } |
| 191 | + |
| 192 | + |
| 193 | +# ============================================================================= |
| 194 | +# Shared Mock Service Fixtures |
| 195 | +# ============================================================================= |
| 196 | + |
| 197 | + |
| 198 | +@pytest.fixture |
| 199 | +def fake_image_base64(): |
| 200 | + """Base64-encoded fake image data for testing uploads.""" |
| 201 | + import base64 |
| 202 | + return base64.b64encode(b"fake-image-data").decode() |
| 203 | + |
| 204 | + |
| 205 | +@pytest.fixture |
| 206 | +def mock_cosmos_service_instance(): |
| 207 | + """Pre-configured AsyncMock for CosmosDB service. |
| 208 | +
|
| 209 | + Returns a mock with common methods pre-configured. Use in tests that |
| 210 | + need a Cosmos service mock without patching. |
| 211 | + """ |
| 212 | + from unittest.mock import AsyncMock |
| 213 | + mock = AsyncMock() |
| 214 | + mock.add_message_to_conversation = AsyncMock() |
| 215 | + mock.get_conversation = AsyncMock(return_value=None) |
| 216 | + mock.upsert_conversation = AsyncMock() |
| 217 | + mock.get_all_products = AsyncMock(return_value=[]) |
| 218 | + mock.get_product_by_sku = AsyncMock(return_value=None) |
| 219 | + mock.upsert_product = AsyncMock() |
| 220 | + mock.delete_product = AsyncMock(return_value=True) |
| 221 | + return mock |
| 222 | + |
| 223 | + |
| 224 | +@pytest.fixture |
| 225 | +def mock_blob_service_instance(): |
| 226 | + """Pre-configured AsyncMock for Blob Storage service. |
| 227 | +
|
| 228 | + Returns a mock with common attributes set up. Use in tests that need |
| 229 | + a blob service mock without patching. |
| 230 | + """ |
| 231 | + from unittest.mock import AsyncMock, MagicMock |
| 232 | + mock = AsyncMock() |
| 233 | + mock.initialize = AsyncMock() |
| 234 | + |
| 235 | + # Set up container mocks |
| 236 | + mock_blob_client = AsyncMock() |
| 237 | + mock_blob_client.upload_blob = AsyncMock() |
| 238 | + mock_blob_client.url = "https://test.blob.core.windows.net/images/test.jpg" |
| 239 | + |
| 240 | + mock_container = MagicMock() |
| 241 | + mock_container.get_blob_client = MagicMock(return_value=mock_blob_client) |
| 242 | + |
| 243 | + mock._product_images_container = mock_container |
| 244 | + mock._generated_images_container = mock_container |
| 245 | + mock._mock_blob_client = mock_blob_client # Expose for assertions |
| 246 | + |
| 247 | + return mock |
| 248 | + |
| 249 | + |
| 250 | +@pytest.fixture |
| 251 | +def mock_orchestrator_instance(): |
| 252 | + """Pre-configured AsyncMock for ContentGenerationOrchestrator. |
| 253 | +
|
| 254 | + Returns a mock with common methods pre-configured. |
| 255 | + """ |
| 256 | + from unittest.mock import AsyncMock |
| 257 | + mock = AsyncMock() |
| 258 | + mock.parse_brief = AsyncMock() |
| 259 | + mock.generate_content_stream = AsyncMock() |
| 260 | + mock.process_message = AsyncMock() |
| 261 | + mock.initialize = AsyncMock() |
| 262 | + mock.confirm_brief = AsyncMock() |
| 263 | + return mock |
| 264 | + |
| 265 | + |
| 266 | +def create_mock_process_message(responses): |
| 267 | + """Factory to create mock_process_message async generator. |
| 268 | +
|
| 269 | + Args: |
| 270 | + responses: List of dicts to yield from the generator |
| 271 | +
|
| 272 | + Returns: |
| 273 | + Async generator function suitable for mock_orchestrator.process_message |
| 274 | +
|
| 275 | + Example: |
| 276 | + mock_orchestrator.process_message = create_mock_process_message([ |
| 277 | + {"type": "message", "content": "Hello", "is_final": True} |
| 278 | + ]) |
| 279 | + """ |
| 280 | + async def mock_process_message(*_args, **_kwargs): |
| 281 | + for response in responses: |
| 282 | + yield response |
| 283 | + return mock_process_message |
| 284 | + |
| 285 | + |
| 286 | +def create_mock_generate_content_stream(responses): |
| 287 | + """Factory to create mock_generate_content_stream async generator. |
| 288 | +
|
| 289 | + Args: |
| 290 | + responses: List of dicts to yield from the generator |
| 291 | +
|
| 292 | + Returns: |
| 293 | + Async generator function for mock_orchestrator.generate_content_stream |
| 294 | + """ |
| 295 | + async def mock_generate_content_stream(*_args, **_kwargs): |
| 296 | + for response in responses: |
| 297 | + yield response |
| 298 | + return mock_generate_content_stream |
0 commit comments