Python: [Purview] Add Caching and background processing in Python Purview Middleware (#1844)

* [PythonPurview] Add Caching and background processing

* [PythonPurview] Updates based on comments
This commit is contained in:
Rishabh Chawla
2025-11-07 07:43:22 +00:00
committed by GitHub
parent 820c6afe09
commit 64826b8f56
21 changed files with 1657 additions and 124 deletions
+196
View File
@@ -0,0 +1,196 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for Purview cache provider."""
import asyncio
from agent_framework_purview._cache import (
InMemoryCacheProvider,
create_protection_scopes_cache_key,
)
from agent_framework_purview._models import PolicyLocation, ProtectionScopesRequest
class TestInMemoryCacheProvider:
"""Test InMemoryCacheProvider functionality."""
async def test_cache_set_and_get(self) -> None:
"""Test basic set and get operations."""
cache = InMemoryCacheProvider()
await cache.set("key1", "value1")
result = await cache.get("key1")
assert result == "value1"
async def test_cache_get_nonexistent_key(self) -> None:
"""Test get returns None for non-existent key."""
cache = InMemoryCacheProvider()
result = await cache.get("nonexistent")
assert result is None
async def test_cache_expiration(self) -> None:
"""Test that cached values expire after TTL."""
cache = InMemoryCacheProvider(default_ttl_seconds=1)
await cache.set("key1", "value1")
result = await cache.get("key1")
assert result == "value1"
await asyncio.sleep(1.1)
result = await cache.get("key1")
assert result is None
async def test_cache_custom_ttl(self) -> None:
"""Test that custom TTL overrides default."""
cache = InMemoryCacheProvider(default_ttl_seconds=10)
await cache.set("key1", "value1", ttl_seconds=1)
result = await cache.get("key1")
assert result == "value1"
await asyncio.sleep(1.1)
result = await cache.get("key1")
assert result is None
async def test_cache_update_existing_key(self) -> None:
"""Test updating an existing cache entry."""
cache = InMemoryCacheProvider()
await cache.set("key1", "value1")
await cache.set("key1", "value2")
result = await cache.get("key1")
assert result == "value2"
async def test_cache_remove(self) -> None:
"""Test removing a cache entry."""
cache = InMemoryCacheProvider()
await cache.set("key1", "value1")
await cache.remove("key1")
result = await cache.get("key1")
assert result is None
async def test_cache_remove_nonexistent_key(self) -> None:
"""Test removing non-existent key does not raise error."""
cache = InMemoryCacheProvider()
await cache.remove("nonexistent")
async def test_cache_size_limit_eviction(self) -> None:
"""Test that cache evicts old entries when size limit is reached."""
cache = InMemoryCacheProvider(max_size_bytes=200)
await cache.set("key1", "a" * 50)
await cache.set("key2", "b" * 50)
await cache.set("key3", "c" * 50)
await cache.set("key4", "d" * 100)
result1 = await cache.get("key1")
assert result1 is None
async def test_estimate_size_with_pydantic_model(self) -> None:
"""Test size estimation with Pydantic models."""
cache = InMemoryCacheProvider()
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
await cache.set("key1", request)
result = await cache.get("key1")
assert result == request
async def test_estimate_size_fallback(self) -> None:
"""Test size estimation fallback for non-serializable objects."""
cache = InMemoryCacheProvider()
class CustomObject:
pass
obj = CustomObject()
await cache.set("key1", obj)
result = await cache.get("key1")
assert result == obj
async def test_cache_multiple_updates(self) -> None:
"""Test that updating a key multiple times maintains correct size tracking."""
cache = InMemoryCacheProvider(max_size_bytes=1000)
await cache.set("key1", "a" * 100)
initial_size = cache._current_size_bytes
await cache.set("key1", "b" * 200)
assert cache._current_size_bytes != initial_size
async def test_eviction_with_stale_heap_entries(self) -> None:
"""Test that eviction correctly handles stale heap entries."""
cache = InMemoryCacheProvider(max_size_bytes=500)
await cache.set("key1", "a" * 100, ttl_seconds=10)
await cache.set("key2", "b" * 100, ttl_seconds=10)
await cache.set("key1", "c" * 100, ttl_seconds=20)
await cache.set("key3", "d" * 300)
result = await cache.get("key1")
assert result is not None
class TestCreateProtectionScopesCacheKey:
"""Test cache key generation for ProtectionScopesRequest."""
def test_cache_key_deterministic(self) -> None:
"""Test that same request generates same cache key."""
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request1 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
request2 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
key1 = create_protection_scopes_cache_key(request1)
key2 = create_protection_scopes_cache_key(request2)
assert key1 == key2
def test_cache_key_different_for_different_requests(self) -> None:
"""Test that different requests generate different cache keys."""
location1 = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id1"})
location2 = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id2"})
request1 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location1])
request2 = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location2])
key1 = create_protection_scopes_cache_key(request1)
key2 = create_protection_scopes_cache_key(request2)
assert key1 != key2
def test_cache_key_excludes_correlation_id(self) -> None:
"""Test that correlation_id is excluded from cache key."""
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request1 = ProtectionScopesRequest(
user_id="user1", tenant_id="tenant1", locations=[location], correlation_id="corr1"
)
request2 = ProtectionScopesRequest(
user_id="user1", tenant_id="tenant1", locations=[location], correlation_id="corr2"
)
key1 = create_protection_scopes_cache_key(request1)
key2 = create_protection_scopes_cache_key(request2)
assert key1 == key2
def test_cache_key_format(self) -> None:
"""Test that cache key has expected format."""
location = PolicyLocation(**{"@odata.type": "microsoft.graph.policyLocationApplication", "value": "app-id"})
request = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1", locations=[location])
key = create_protection_scopes_cache_key(request)
assert key.startswith("purview:protection_scopes:")
assert len(key) > len("purview:protection_scopes:")
@@ -74,7 +74,8 @@ class TestPurviewChatPolicyMiddleware:
await middleware.process(chat_context, mock_next)
assert chat_context.terminate
assert chat_context.result
msg = chat_context.result[0] # type: ignore[index]
assert hasattr(chat_context.result, "messages")
msg = chat_context.result.messages[0]
assert msg.role in ("system", Role.SYSTEM)
assert "blocked" in msg.text.lower()
@@ -112,7 +113,7 @@ class TestPurviewChatPolicyMiddleware:
chat_options=chat_options,
is_streaming=True,
)
with patch.object(middleware._processor, "process_messages", return_value=False) as mock_proc:
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
async def mock_next(ctx: ChatContext) -> None:
ctx.result = MagicMock()
@@ -123,7 +124,10 @@ class TestPurviewChatPolicyMiddleware:
async def test_chat_middleware_handles_post_check_exception(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
"""Test that exceptions in post-check are logged but don't affect result."""
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
# Set ignore_exceptions to True to test exception suppression
middleware._settings.ignore_exceptions = True
call_count = 0
async def mock_process_messages(*args, **kwargs):
@@ -136,7 +140,6 @@ class TestPurviewChatPolicyMiddleware:
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
# Create a mock result with messages attribute
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
ctx.result = result
@@ -147,3 +150,127 @@ class TestPurviewChatPolicyMiddleware:
assert call_count == 2
# Result should still be set
assert chat_context.result is not None
async def test_chat_middleware_uses_consistent_user_id(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
"""Test that the same user_id from pre-check is used in post-check."""
captured_user_ids = []
async def mock_process_messages(messages, activity, user_id=None):
captured_user_ids.append(user_id)
return (False, "resolved-user-123")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
ctx.result = result
await middleware.process(chat_context, mock_next)
# Should have been called twice
assert len(captured_user_ids) == 2
# First call should have None (no user_id provided yet)
assert captured_user_ids[0] is None
# Second call should have the resolved user_id from first call
assert captured_user_ids[1] == "resolved-user-123"
async def test_chat_middleware_handles_payment_required_pre_check(self, mock_credential: AsyncMock) -> None:
"""Test that 402 in pre-check is handled based on settings."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
# Test with ignore_payment_required=False
settings = PurviewSettings(app_name="Test App", ignore_payment_required=False)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
)
async def mock_process_messages(*args, **kwargs):
raise PurviewPaymentRequiredError("Payment required")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
raise AssertionError("next should not be called")
# Should raise the exception
with pytest.raises(PurviewPaymentRequiredError):
await middleware.process(context, mock_next)
async def test_chat_middleware_ignores_payment_required_when_configured(self, mock_credential: AsyncMock) -> None:
"""Test that 402 is ignored when ignore_payment_required=True."""
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
)
async def mock_process_messages(*args, **kwargs):
raise PurviewPaymentRequiredError("Payment required")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
context.result = result
# Should not raise, just log
await middleware.process(context, mock_next)
# Next should have been called
assert context.result is not None
async def test_chat_middleware_handles_result_without_messages_attribute(
self, middleware: PurviewChatPolicyMiddleware, chat_context: ChatContext
) -> None:
"""Test middleware handles result that doesn't have messages attribute."""
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
async def mock_next(ctx: ChatContext) -> None:
# Set result to something without messages attribute
ctx.result = "Some string result"
await middleware.process(chat_context, mock_next)
# Should not crash, result should be unchanged
assert chat_context.result == "Some string result"
async def test_chat_middleware_with_ignore_exceptions(self, mock_credential: AsyncMock) -> None:
"""Test that middleware respects ignore_exceptions setting."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=True)
middleware = PurviewChatPolicyMiddleware(mock_credential, settings)
chat_client = DummyChatClient()
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
)
async def mock_process_messages(*args, **kwargs):
raise ValueError("Some error")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx: ChatContext) -> None:
result = MagicMock()
result.messages = [ChatMessage(role=Role.ASSISTANT, text="Response")]
context.result = result
# Should not raise, just log
await middleware.process(context, mock_next)
# Next should have been called
assert context.result is not None
@@ -12,6 +12,7 @@ from agent_framework_purview import PurviewSettings
from agent_framework_purview._client import PurviewClient
from agent_framework_purview._exceptions import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
@@ -157,6 +158,7 @@ class TestPurviewClient:
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {"id": "response-123", "protectionScopeState": "notModified"}
with patch.object(client._client, "post", return_value=mock_response):
@@ -174,6 +176,7 @@ class TestPurviewClient:
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {} # Add headers attribute
mock_response.json.return_value = {"scopeIdentifier": "scope-123", "value": []}
with patch.object(client._client, "post", return_value=mock_response):
@@ -236,3 +239,157 @@ class TestPurviewClient:
pytest.raises(PurviewRequestError, match="Purview request failed"),
):
await client.process_content(request)
async def test_prefer_header_sent_when_process_inline_true(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that Prefer: evaluateInline header is sent when process_inline is True."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
process_inline=True,
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "Prefer" in posted_headers
assert posted_headers["Prefer"] == "evaluateInline"
async def test_prefer_header_not_sent_when_process_inline_false(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that Prefer header is not sent when process_inline is False."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
process_inline=False,
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "Prefer" not in posted_headers
async def test_prefer_header_not_sent_when_process_inline_none(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that Prefer header is not sent when process_inline is None."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
process_inline=None,
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "Prefer" not in posted_headers
async def test_scope_identifier_extraction_from_etag(self, client: PurviewClient) -> None:
"""Test that scope_identifier is extracted from ETag header."""
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {"etag": '"test-scope-id"'}
mock_response.json.return_value = {"value": []}
with patch.object(client._client, "post", return_value=mock_response):
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
response = await client.get_protection_scopes(req)
assert response.scope_identifier == "test-scope-id"
async def test_scope_identifier_sent_as_if_none_match_header(
self, client: PurviewClient, content_to_process_factory
) -> None:
"""Test that scope_identifier is sent as If-None-Match header."""
content = content_to_process_factory()
request = ProcessContentRequest(
content_to_process=content,
user_id="user-123",
tenant_id="tenant-456",
scope_identifier="test-scope-id",
)
posted_headers = {}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {}
mock_response.json.return_value = {}
async def capture_post(url, json, headers):
posted_headers.update(headers)
return mock_response
with patch.object(client._client, "post", side_effect=capture_post):
await client.process_content(request)
assert "If-None-Match" in posted_headers
assert posted_headers["If-None-Match"] == "test-scope-id"
async def test_402_payment_required_raises_exception_by_default(self, client: PurviewClient) -> None:
"""Test that 402 raises exception when ignore_payment_required is False."""
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 402
mock_response.text = "Payment required"
with patch.object(client._client, "post", return_value=mock_response):
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
with pytest.raises(PurviewPaymentRequiredError):
await client.get_protection_scopes(req)
async def test_402_payment_required_returns_empty_when_ignored(self, mock_credential: MagicMock) -> None:
"""Test that 402 returns empty response when ignore_payment_required is True."""
settings = PurviewSettings(app_name="Test App", ignore_payment_required=True)
client = PurviewClient(mock_credential, settings)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 402
mock_response.text = "Payment required"
with patch.object(client._client, "post", return_value=mock_response):
req = ProtectionScopesRequest(user_id="user1", tenant_id="tenant1")
response = await client.get_protection_scopes(req)
# Should return empty response without raising
assert response is not None
assert response.scopes is None or response.scopes == []
await client.close()
@@ -4,6 +4,7 @@
from agent_framework_purview import (
PurviewAuthenticationError,
PurviewPaymentRequiredError,
PurviewRateLimitError,
PurviewRequestError,
PurviewServiceError,
@@ -25,6 +26,12 @@ class TestPurviewExceptions:
assert str(error) == "Authentication failed"
assert isinstance(error, PurviewServiceError)
def test_purview_payment_required_error(self) -> None:
"""Test PurviewPaymentRequiredError exception."""
error = PurviewPaymentRequiredError("Payment required")
assert str(error) == "Payment required"
assert isinstance(error, PurviewServiceError)
def test_purview_rate_limit_error(self) -> None:
"""Test PurviewRateLimitError exception."""
error = PurviewRateLimitError("Rate limit exceeded")
@@ -120,6 +120,9 @@ class TestPurviewPolicyMiddleware:
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test middleware handles result that doesn't have messages attribute."""
# Set ignore_exceptions to True so AttributeError is caught and logged
middleware._settings.ignore_exceptions = True
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Hello")])
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")):
@@ -153,7 +156,10 @@ class TestPurviewPolicyMiddleware:
async def test_middleware_handles_pre_check_exception(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that exceptions in pre-check are logged but don't stop processing."""
"""Test that exceptions in pre-check are logged but don't stop processing when ignore_exceptions=True."""
# Set ignore_exceptions to True
middleware._settings.ignore_exceptions = True
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
with patch.object(
@@ -175,7 +181,10 @@ class TestPurviewPolicyMiddleware:
async def test_middleware_handles_post_check_exception(
self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock
) -> None:
"""Test that exceptions in post-check are logged but don't affect result."""
"""Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True."""
# Set ignore_exceptions to True
middleware._settings.ignore_exceptions = True
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
call_count = 0
@@ -199,3 +208,49 @@ class TestPurviewPolicyMiddleware:
# Result should still be set
assert context.result is not None
assert hasattr(context.result, "messages")
async def test_middleware_with_ignore_exceptions_true(self, mock_credential: AsyncMock) -> None:
"""Test that middleware logs but doesn't throw when ignore_exceptions is True."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=True)
middleware = PurviewPolicyMiddleware(mock_credential, settings)
mock_agent = MagicMock()
mock_agent.name = "test-agent"
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
# Mock processor to raise an exception
async def mock_process_messages(*args, **kwargs):
raise ValueError("Test error")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx):
ctx.result = AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="Response")])
# Should not raise, just log
await middleware.process(context, mock_next)
# Result should be set because next was called despite the error
assert context.result is not None
async def test_middleware_with_ignore_exceptions_false(self, mock_credential: AsyncMock) -> None:
"""Test that middleware throws exceptions when ignore_exceptions is False."""
settings = PurviewSettings(app_name="Test App", ignore_exceptions=False)
middleware = PurviewPolicyMiddleware(mock_credential, settings)
mock_agent = MagicMock()
mock_agent.name = "test-agent"
context = AgentRunContext(agent=mock_agent, messages=[ChatMessage(role=Role.USER, text="Test")])
# Mock processor to raise an exception
async def mock_process_messages(*args, **kwargs):
raise ValueError("Test error")
with patch.object(middleware._processor, "process_messages", side_effect=mock_process_messages):
async def mock_next(ctx):
pass
# Should raise the exception
with pytest.raises(ValueError, match="Test error"):
await middleware.process(context, mock_next)
+2 -5
View File
@@ -237,10 +237,7 @@ class TestModelDeserialization:
dumped = request.model_dump(by_alias=True, exclude_none=True, mode="json")
# Check that excluded fields are not present
assert "user_id" not in dumped
assert "tenant_id" not in dumped
assert "user_id" in dumped
assert "tenant_id" in dumped
assert "correlation_id" not in dumped
# Check that content is present
assert "contentToProcess" in dumped
+271 -14
View File
@@ -170,7 +170,7 @@ class TestScopedContentProcessor:
request = process_content_request_factory()
response = ProtectionScopesResponse(**{"value": None})
should_process, actions = processor._check_applicable_scopes(request, response)
should_process, actions, execution_mode = processor._check_applicable_scopes(request, response)
assert should_process is False
assert actions == []
@@ -200,7 +200,7 @@ class TestScopedContentProcessor:
})
response = ProtectionScopesResponse(**{"value": [scope]})
should_process, actions = processor._check_applicable_scopes(request, response)
should_process, actions, execution_mode = processor._check_applicable_scopes(request, response)
assert should_process is True
assert len(actions) == 1
@@ -220,7 +220,7 @@ class TestScopedContentProcessor:
async def test_process_with_scopes_calls_client_methods(
self, processor: ScopedContentProcessor, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test _process_with_scopes calls get_protection_scopes and process_content."""
"""Test _process_with_scopes calls get_protection_scopes when scopes response is empty."""
from agent_framework_purview._models import (
ContentActivitiesResponse,
ProtectionScopesResponse,
@@ -237,9 +237,10 @@ class TestScopedContentProcessor:
response = await processor._process_with_scopes(request)
mock_client.get_protection_scopes.assert_called_once()
# When no scopes apply, process_content is not called (activities are sent in background)
mock_client.process_content.assert_not_called()
mock_client.send_content_activities.assert_called_once()
assert response.id is None
# The response should have id=204 (No Content) when no scopes apply
assert response.id == "204"
async def test_map_messages_with_user_id_in_additional_properties(self, mock_client: AsyncMock) -> None:
"""Test user_id extraction from message additional_properties."""
@@ -308,7 +309,7 @@ class TestScopedContentProcessor:
async def test_process_content_sends_activities_when_not_applicable(
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test that content activities are sent when scopes don't apply."""
"""Test that response is returned when scopes don't apply (activities sent in background)."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
@@ -325,7 +326,7 @@ class TestScopedContentProcessor:
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
# Mock send_content_activities to return success
# Mock send_content_activities to return success (called in background)
mock_ca_response = MagicMock()
mock_ca_response.error = None
mock_client.send_content_activities.return_value = mock_ca_response
@@ -334,14 +335,13 @@ class TestScopedContentProcessor:
mock_client.get_protection_scopes.assert_called_once()
mock_client.process_content.assert_not_called()
mock_client.send_content_activities.assert_called_once()
# When content activities succeed, response has no errors (processing_errors can be None or empty)
assert response.processing_errors is None or response.processing_errors == []
# Response should have id=204 when no scopes apply
assert response.id == "204"
async def test_process_content_handles_activities_error(
self, mock_client: AsyncMock, process_content_request_factory
) -> None:
"""Test error handling when content activities fail."""
"""Test that errors in background activities don't affect the response."""
settings = PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
@@ -358,12 +358,269 @@ class TestScopedContentProcessor:
mock_ps_response.scopes = []
mock_client.get_protection_scopes.return_value = mock_ps_response
# Mock send_content_activities to return error
# Mock send_content_activities to return error (called in background task)
mock_ca_response = MagicMock()
mock_ca_response.error = "Test error message"
mock_client.send_content_activities.return_value = mock_ca_response
response = await processor._process_with_scopes(pc_request)
assert len(response.processing_errors) == 1
assert response.processing_errors[0].message == "Test error message"
# Since activities are sent in background, errors don't affect the response
# Response should have id=204 when no scopes apply
assert response.id == "204"
class TestUserIdResolution:
"""Test user ID resolution from various sources."""
@pytest.fixture
def mock_client(self) -> AsyncMock:
"""Create a mock Purview client."""
client = AsyncMock()
client.get_user_info_from_token = AsyncMock(
return_value={
"tenant_id": "12345678-1234-1234-1234-123456789012",
"user_id": "11111111-1111-1111-1111-111111111111",
"client_id": "12345678-1234-1234-1234-123456789012",
}
)
return client
@pytest.fixture
def settings(self) -> PurviewSettings:
"""Create settings."""
return PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
purview_app_location=PurviewAppLocation(
location_type=PurviewLocationType.APPLICATION, location_value="app-id"
),
)
async def test_user_id_from_token_when_no_other_source(self, mock_client: AsyncMock) -> None:
"""Test user_id is extracted from token when no other source available."""
settings = PurviewSettings(app_name="Test App") # No tenant_id or app_location
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
mock_client.get_user_info_from_token.assert_called_once()
assert user_id == "11111111-1111-1111-1111-111111111111"
async def test_user_id_from_additional_properties_takes_priority(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test user_id from additional_properties takes priority over token."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER,
text="Test",
additional_properties={"user_id": "22222222-2222-2222-2222-222222222222"},
)
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
# Token info should not be called since we have user_id in message
mock_client.get_user_info_from_token.assert_not_called()
assert user_id == "22222222-2222-2222-2222-222222222222"
async def test_user_id_from_author_name_as_fallback(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test user_id is extracted from author_name when it's a valid GUID."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER,
text="Test",
author_name="33333333-3333-3333-3333-333333333333",
)
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
assert user_id == "33333333-3333-3333-3333-333333333333"
async def test_author_name_ignored_if_not_valid_guid(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test author_name is ignored if it's not a valid GUID."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER,
text="Test",
author_name="John Doe", # Not a GUID
)
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
# Should return empty since author_name is not a valid GUID
assert user_id is None
assert len(requests) == 0
async def test_provided_user_id_used_as_last_resort(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test provided_user_id parameter is used as last resort."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test")]
requests, user_id = await processor._map_messages(
messages, Activity.UPLOAD_TEXT, provided_user_id="44444444-4444-4444-4444-444444444444"
)
assert user_id == "44444444-4444-4444-4444-444444444444"
async def test_invalid_provided_user_id_ignored(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
"""Test invalid provided_user_id is ignored."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [ChatMessage(role=Role.USER, text="Test")]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT, provided_user_id="not-a-guid")
assert user_id is None
assert len(requests) == 0
async def test_multiple_messages_same_user_id(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
"""Test that all messages use the same resolved user_id."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(
role=Role.USER, text="First", additional_properties={"user_id": "55555555-5555-5555-5555-555555555555"}
),
ChatMessage(role=Role.ASSISTANT, text="Response"),
ChatMessage(role=Role.USER, text="Second"),
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
assert user_id == "55555555-5555-5555-5555-555555555555"
# All requests should have the same user_id
assert all(req.user_id == "55555555-5555-5555-5555-555555555555" for req in requests)
async def test_first_valid_user_id_in_messages_is_used(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test that the first valid user_id found in messages is used for all."""
processor = ScopedContentProcessor(mock_client, settings)
messages = [
ChatMessage(role=Role.USER, text="First", author_name="Not a GUID"),
ChatMessage(
role=Role.ASSISTANT,
text="Response",
additional_properties={"user_id": "66666666-6666-6666-6666-666666666666"},
),
ChatMessage(
role=Role.USER, text="Third", additional_properties={"user_id": "77777777-7777-7777-7777-777777777777"}
),
]
requests, user_id = await processor._map_messages(messages, Activity.UPLOAD_TEXT)
# First valid user_id (from second message) should be used
assert user_id == "66666666-6666-6666-6666-666666666666"
assert all(req.user_id == "66666666-6666-6666-6666-666666666666" for req in requests)
class TestScopedContentProcessorCaching:
"""Test caching functionality in ScopedContentProcessor."""
@pytest.fixture
def mock_client(self) -> AsyncMock:
"""Create a mock Purview client."""
client = AsyncMock()
client.get_user_info_from_token = AsyncMock(
return_value={
"tenant_id": "12345678-1234-1234-1234-123456789012",
"user_id": "12345678-1234-1234-1234-123456789012",
"client_id": "12345678-1234-1234-1234-123456789012",
}
)
client.get_protection_scopes = AsyncMock()
return client
@pytest.fixture
def settings(self) -> PurviewSettings:
"""Create test settings."""
location = PurviewAppLocation(location_type=PurviewLocationType.APPLICATION, location_value="app-id")
return PurviewSettings(
app_name="Test App",
tenant_id="12345678-1234-1234-1234-123456789012",
default_user_id="12345678-1234-1234-1234-123456789012",
purview_app_location=location,
)
async def test_protection_scopes_cached_on_first_call(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test that protection scopes response is cached after first call."""
from agent_framework_purview._cache import InMemoryCacheProvider
from agent_framework_purview._models import ProtectionScopesResponse
cache_provider = InMemoryCacheProvider()
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
mock_client.get_protection_scopes.return_value = ProtectionScopesResponse(
scope_identifier="scope-123", scopes=[]
)
messages = [ChatMessage(role=Role.USER, text="Test")]
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
mock_client.get_protection_scopes.assert_called_once()
await processor.process_messages(messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012")
mock_client.get_protection_scopes.assert_called_once()
async def test_payment_required_exception_cached_at_tenant_level(
self, mock_client: AsyncMock, settings: PurviewSettings
) -> None:
"""Test that 402 payment required exceptions are cached at tenant level."""
from agent_framework_purview._cache import InMemoryCacheProvider
from agent_framework_purview._exceptions import PurviewPaymentRequiredError
cache_provider = InMemoryCacheProvider()
processor = ScopedContentProcessor(mock_client, settings, cache_provider=cache_provider)
mock_client.get_protection_scopes.side_effect = PurviewPaymentRequiredError("Payment required")
messages = [ChatMessage(role=Role.USER, text="Test")]
with pytest.raises(PurviewPaymentRequiredError):
await processor.process_messages(
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
)
mock_client.get_protection_scopes.assert_called_once()
with pytest.raises(PurviewPaymentRequiredError):
await processor.process_messages(
messages, Activity.UPLOAD_TEXT, user_id="12345678-1234-1234-1234-123456789012"
)
mock_client.get_protection_scopes.assert_called_once()
async def test_custom_cache_provider_used(self, mock_client: AsyncMock, settings: PurviewSettings) -> None:
"""Test that custom cache provider is used when provided."""
from agent_framework_purview._cache import InMemoryCacheProvider
custom_cache = InMemoryCacheProvider(default_ttl_seconds=60)
processor = ScopedContentProcessor(mock_client, settings, cache_provider=custom_cache)
assert processor._cache is custom_cache
assert processor._cache._default_ttl == 60
@@ -18,7 +18,6 @@ class TestPurviewSettings:
assert settings.graph_base_uri == "https://graph.microsoft.com/v1.0/"
assert settings.tenant_id is None
assert settings.purview_app_location is None
assert settings.process_inline is False
def test_settings_with_custom_values(self) -> None:
"""Test PurviewSettings with custom values."""
@@ -28,13 +27,11 @@ class TestPurviewSettings:
app_name="Test App",
graph_base_uri="https://graph.microsoft-ppe.com",
tenant_id="test-tenant-id",
process_inline=True,
purview_app_location=app_location,
)
assert settings.graph_base_uri == "https://graph.microsoft-ppe.com"
assert settings.tenant_id == "test-tenant-id"
assert settings.process_inline is True
assert settings.purview_app_location.location_value == "app-123"
@pytest.mark.parametrize(