Skip to content

Commit b02635c

Browse files
fix: resolve conftest.py conflict by adopting dev branch version
1 parent d92d0f2 commit b02635c

File tree

1 file changed

+276
-59
lines changed

1 file changed

+276
-59
lines changed

content-gen/src/tests/conftest.py

Lines changed: 276 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,298 @@
11
"""
2-
Pytest configuration for Content Generation backend tests.
2+
Pytest configuration and fixtures for backend tests.
33
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
98
"""
109

11-
import sys
12-
import os
1310
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",
1477

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",
2681

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",
2887

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",
3692

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",
4097

98+
# AI Foundry (disabled for tests)
99+
"USE_FOUNDRY": "false",
41100

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
43120

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()
50126

51127

52128
@pytest.fixture
53-
def sample_creative_brief():
54-
"""Sample creative brief for testing."""
129+
def sample_product_dict():
130+
"""Sample product data as dictionary."""
55131
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()
65142
}
66143

67144

68145
@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."""
71155
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"
81165
}
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

Comments
 (0)