22
33import pytest
44from fastapi import HTTPException , status
5+ from pydantic_ai import ModelAPIError , ModelHTTPError , UnexpectedModelBehavior
56from pytest_mock import MockerFixture
67
7- from models .config import QuestionValidityConfig , QuestionValidityShieldConfiguration
8+ from models .common .moderation import ShieldModerationBlocked , ShieldModerationPassed
9+ from models .config import (
10+ QuestionValidityConfig ,
11+ QuestionValidityShieldConfiguration ,
12+ ShieldConfiguration ,
13+ )
814from utils .shields import (
915 append_turn_to_conversation ,
1016 get_shields_for_request ,
17+ run_shield_moderation_v2 ,
1118 validate_shield_ids_override ,
1219)
1320
1421
15- def _shield (name : str ) -> QuestionValidityShieldConfiguration :
22+ def _shield_config (name : str ) -> QuestionValidityShieldConfiguration :
1623 """Build a minimal question-validity shield configuration for tests."""
1724 return QuestionValidityShieldConfiguration (
1825 name = name ,
@@ -133,30 +140,174 @@ def test_raises_422_when_empty_list_shield_ids_and_override_disabled(
133140 assert exc_info .value .status_code == status .HTTP_422_UNPROCESSABLE_ENTITY
134141
135142
143+ class TestRunShieldModerationV2 :
144+ """Tests for run_shield_moderation_v2 function."""
145+
146+ @pytest .mark .asyncio
147+ async def test_returns_passed_when_no_shields (self ) -> None :
148+ """Return ShieldModerationPassed when shield list is empty."""
149+ result = await run_shield_moderation_v2 ("test input" , [])
150+ assert isinstance (result , ShieldModerationPassed )
151+
152+ @pytest .mark .asyncio
153+ async def test_returns_passed_when_all_shields_pass (
154+ self , mocker : MockerFixture
155+ ) -> None :
156+ """Return ShieldModerationPassed when every shield passes."""
157+ mock_shield = mocker .Mock ()
158+ mock_shield .run = mocker .AsyncMock (return_value = ShieldModerationPassed ())
159+ mocker .patch ("utils.shields.build_shield" , return_value = mock_shield )
160+
161+ shields : list [ShieldConfiguration ] = [
162+ _shield_config ("s1" ),
163+ _shield_config ("s2" ),
164+ ]
165+ result = await run_shield_moderation_v2 ("test input" , shields )
166+
167+ assert isinstance (result , ShieldModerationPassed )
168+ assert mock_shield .run .call_count == 2
169+
170+ @pytest .mark .asyncio
171+ async def test_returns_blocked_on_first_block (self , mocker : MockerFixture ) -> None :
172+ """Return blocked result from first shield that blocks."""
173+ blocked = ShieldModerationBlocked (message = "rejected" , moderation_id = "modr-123" )
174+ mock_shield = mocker .Mock ()
175+ mock_shield .run = mocker .AsyncMock (return_value = blocked )
176+ mocker .patch ("utils.shields.build_shield" , return_value = mock_shield )
177+
178+ shields : list [ShieldConfiguration ] = [
179+ _shield_config ("s1" ),
180+ _shield_config ("s2" ),
181+ ]
182+ result = await run_shield_moderation_v2 ("test input" , shields )
183+
184+ assert isinstance (result , ShieldModerationBlocked )
185+ assert result .message == "rejected"
186+ mock_shield .run .assert_called_once ()
187+
188+ @pytest .mark .asyncio
189+ async def test_filters_by_selected_shield_ids (self , mocker : MockerFixture ) -> None :
190+ """Only run shields matching the selected IDs."""
191+ mock_shield = mocker .Mock ()
192+ mock_shield .run = mocker .AsyncMock (return_value = ShieldModerationPassed ())
193+ mocker .patch ("utils.shields.build_shield" , return_value = mock_shield )
194+
195+ shields : list [ShieldConfiguration ] = [
196+ _shield_config ("s1" ),
197+ _shield_config ("s2" ),
198+ _shield_config ("s3" ),
199+ ]
200+ result = await run_shield_moderation_v2 (
201+ "test input" , shields , selected_shield_ids = ["s2" ]
202+ )
203+
204+ assert isinstance (result , ShieldModerationPassed )
205+ mock_shield .run .assert_called_once ()
206+
207+ @pytest .mark .asyncio
208+ async def test_shields_stops_on_first_block (self , mocker : MockerFixture ) -> None :
209+ """Stop at the first blocking shield."""
210+ blocked = ShieldModerationBlocked (message = "rejected" , moderation_id = "modr-789" )
211+ mock_qv_shield = mocker .Mock ()
212+ mock_qv_shield .run = mocker .AsyncMock (return_value = blocked )
213+
214+ mock_redact_shield = mocker .Mock ()
215+ mock_redact_shield .run = mocker .AsyncMock (return_value = ShieldModerationPassed ())
216+
217+ mocker .patch (
218+ "utils.shields.build_shield" ,
219+ side_effect = [mock_qv_shield , mock_redact_shield ],
220+ )
221+
222+ shields : list [ShieldConfiguration ] = [
223+ _shield_config ("s-1" ),
224+ _shield_config ("s-2" ),
225+ ]
226+ result = await run_shield_moderation_v2 ("test input" , shields )
227+
228+ assert isinstance (result , ShieldModerationBlocked )
229+ mock_qv_shield .run .assert_called_once ()
230+ mock_redact_shield .run .assert_not_called ()
231+
232+ @pytest .mark .asyncio
233+ async def test_raises_503_on_model_http_error (self , mocker : MockerFixture ) -> None :
234+ """Raise HTTP 503 when the shield model returns an HTTP error."""
235+ mock_shield = mocker .Mock ()
236+ mock_shield .run = mocker .AsyncMock (
237+ side_effect = ModelHTTPError (
238+ status_code = 500 , model_name = "test-model" , body = None
239+ )
240+ )
241+ mocker .patch ("utils.shields.build_shield" , return_value = mock_shield )
242+
243+ with pytest .raises (HTTPException ) as exc_info :
244+ await run_shield_moderation_v2 ("test input" , [_shield_config ("s1" )])
245+
246+ assert exc_info .value .status_code == status .HTTP_503_SERVICE_UNAVAILABLE
247+
248+ @pytest .mark .asyncio
249+ async def test_raises_503_on_model_api_error (self , mocker : MockerFixture ) -> None :
250+ """Raise HTTP 503 when the shield model is unreachable."""
251+ mock_shield = mocker .Mock ()
252+ mock_shield .run = mocker .AsyncMock (
253+ side_effect = ModelAPIError (
254+ model_name = "test-model" , message = "Connection refused"
255+ )
256+ )
257+ mocker .patch ("utils.shields.build_shield" , return_value = mock_shield )
258+
259+ with pytest .raises (HTTPException ) as exc_info :
260+ await run_shield_moderation_v2 ("test input" , [_shield_config ("s1" )])
261+
262+ assert exc_info .value .status_code == status .HTTP_503_SERVICE_UNAVAILABLE
263+
264+ @pytest .mark .asyncio
265+ async def test_raises_500_on_unexpected_model_behavior (
266+ self , mocker : MockerFixture
267+ ) -> None :
268+ """Raise HTTP 500 when the shield model returns an unexpected response."""
269+ mock_shield = mocker .Mock ()
270+ mock_shield .run = mocker .AsyncMock (
271+ side_effect = UnexpectedModelBehavior ("bad response format" )
272+ )
273+ mocker .patch ("utils.shields.build_shield" , return_value = mock_shield )
274+
275+ with pytest .raises (HTTPException ) as exc_info :
276+ await run_shield_moderation_v2 ("test input" , [_shield_config ("s1" )])
277+
278+ assert exc_info .value .status_code == status .HTTP_500_INTERNAL_SERVER_ERROR
279+
280+
136281class TestGetShieldsForRequest :
137282 """Tests for get_shields_for_request function."""
138283
139284 def test_returns_all_shields_when_shield_ids_none (self ) -> None :
140285 """Return all configured shields when shield_ids is None."""
141- shields = [_shield ("shield-1" ), _shield ("shield-2" )]
286+ shields = [
287+ _shield_config ("shield-1" ),
288+ _shield_config ("shield-2" ),
289+ ]
142290
143291 result = get_shields_for_request (shields , shield_ids = None )
144292
145293 assert result == shields
146294
147295 def test_returns_empty_list_when_shield_ids_empty (self ) -> None :
148296 """Return no shields when an empty shield_ids list is provided."""
149- shields = [_shield ("shield-1" ), _shield ("shield-2" )]
297+ shields = [
298+ _shield_config ("shield-1" ),
299+ _shield_config ("shield-2" ),
300+ ]
150301
151302 result = get_shields_for_request (shields , shield_ids = [])
152303
153304 assert result == []
154305
155306 def test_filters_to_requested_shields_when_all_exist (self ) -> None :
156307 """Return only shields whose names appear in shield_ids."""
157- shield1 = _shield ("shield-1" )
158- shield2 = _shield ("shield-2" )
159- shield3 = _shield ("shield-3" )
308+ shield1 = _shield_config ("shield-1" )
309+ shield2 = _shield_config ("shield-2" )
310+ shield3 = _shield_config ("shield-3" )
160311
161312 result = get_shields_for_request (
162313 [shield1 , shield2 , shield3 ], shield_ids = ["shield-1" , "shield-3" ]
@@ -168,7 +319,8 @@ def test_raises_404_when_requested_shield_not_configured(self) -> None:
168319 """Raise 404 when a requested shield name is not configured."""
169320 with pytest .raises (HTTPException ) as exc_info :
170321 get_shields_for_request (
171- [_shield ("shield-1" )], shield_ids = ["shield-1" , "missing-shield" ]
322+ [_shield_config ("shield-1" )],
323+ shield_ids = ["shield-1" , "missing-shield" ],
172324 )
173325
174326 assert exc_info .value .status_code == status .HTTP_404_NOT_FOUND
0 commit comments