참고소스 수정본
This commit is contained in:
805
참고/guardrails-main/tests/unit_tests/test_api_client.py
Normal file
805
참고/guardrails-main/tests/unit_tests/test_api_client.py
Normal file
@@ -0,0 +1,805 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError, Request, Response
|
||||
|
||||
from guardrails.api_client import GuardrailsApiClient
|
||||
from guardrails.errors import ValidationError
|
||||
from guardrails_ai.types import Guard, ValidationOutcome as IValidationOutcome
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_http_error(status_code: int) -> HTTPStatusError:
|
||||
request = Request("POST", "http://test.com")
|
||||
response = Response(status_code=status_code, request=request)
|
||||
return HTTPStatusError(
|
||||
message=f"HTTP {status_code}", request=request, response=response
|
||||
)
|
||||
|
||||
|
||||
def mock_sync_response(json_data=None, raise_error=None):
|
||||
r = Mock()
|
||||
r.json.return_value = json_data if json_data is not None else {}
|
||||
r.status_code = 200
|
||||
r.is_success = True
|
||||
if raise_error:
|
||||
r.raise_for_status.side_effect = raise_error
|
||||
return r
|
||||
|
||||
|
||||
def mock_async_response(json_data=None, raise_error=None):
|
||||
# Use Mock (not AsyncMock) — httpx response methods are synchronous
|
||||
r = Mock()
|
||||
r.json.return_value = json_data if json_data is not None else {}
|
||||
r.status_code = 200
|
||||
r.is_success = True
|
||||
if raise_error:
|
||||
r.raise_for_status.side_effect = raise_error
|
||||
return r
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockedClient:
|
||||
client: GuardrailsApiClient
|
||||
http: MagicMock
|
||||
ahttp: AsyncMock
|
||||
|
||||
|
||||
def make_client(**kwargs) -> MockedClient:
|
||||
"""Create a GuardrailsApiClient with mocked HTTP clients."""
|
||||
with (
|
||||
patch("guardrails.api_client.Client"),
|
||||
patch("guardrails.api_client.AsyncClient"),
|
||||
):
|
||||
client = GuardrailsApiClient(**kwargs)
|
||||
http = MagicMock()
|
||||
ahttp = AsyncMock()
|
||||
client.http_client = http # type: ignore[assignment]
|
||||
client.ahttp_client = ahttp # type: ignore[assignment]
|
||||
return MockedClient(client=client, http=http, ahttp=ahttp)
|
||||
|
||||
|
||||
def make_guard(guard_id="guard-id-123", name="test-guard") -> Mock:
|
||||
guard = Mock(spec=Guard)
|
||||
guard.id = guard_id
|
||||
guard.name = name
|
||||
guard.model_dump.return_value = {"id": guard_id, "name": name}
|
||||
return guard
|
||||
|
||||
|
||||
def make_sse_chunk(data: dict) -> str:
|
||||
return f"{json.dumps(data)}\n"
|
||||
|
||||
|
||||
def make_stream_ctx(chunks, is_success=True, raise_on_fail=None):
|
||||
"""Sync context manager mock for http_client.stream()."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.is_success = is_success
|
||||
mock_resp.iter_text.return_value = iter(chunks)
|
||||
if raise_on_fail:
|
||||
mock_resp.raise_for_status.side_effect = raise_on_fail
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__ = Mock(return_value=mock_resp)
|
||||
ctx.__exit__ = Mock(return_value=False)
|
||||
return ctx
|
||||
|
||||
|
||||
def make_async_stream_ctx(chunks, is_success=True, raise_on_fail=None):
|
||||
"""Async context manager mock for ahttp_client.stream()."""
|
||||
|
||||
async def aiter():
|
||||
for chunk in chunks:
|
||||
yield chunk
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.is_success = is_success
|
||||
mock_resp.aiter_text.return_value = aiter()
|
||||
if raise_on_fail:
|
||||
mock_resp.raise_for_status.side_effect = raise_on_fail
|
||||
|
||||
ctx = AsyncMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
return ctx
|
||||
|
||||
|
||||
# ─── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGuardrailsApiClientInit:
|
||||
def test_init_with_env_vars(self):
|
||||
with (
|
||||
patch("guardrails.api_client.Client"),
|
||||
patch("guardrails.api_client.AsyncClient"),
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GUARDRAILS_BASE_URL": "http://env.com",
|
||||
"GUARDRAILS_API_KEY": "env-key",
|
||||
},
|
||||
):
|
||||
client = GuardrailsApiClient()
|
||||
|
||||
assert client.base_url == "http://env.com"
|
||||
assert client.api_key == "env-key"
|
||||
assert client.timeout == 300
|
||||
|
||||
def test_init_with_explicit_params(self):
|
||||
with (
|
||||
patch("guardrails.api_client.Client"),
|
||||
patch("guardrails.api_client.AsyncClient"),
|
||||
):
|
||||
client = GuardrailsApiClient(
|
||||
base_url="http://custom.com", api_key="custom-key"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://custom.com"
|
||||
assert client.api_key == "custom-key"
|
||||
assert client.timeout == 300
|
||||
|
||||
def test_init_default_values(self):
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k not in ("GUARDRAILS_BASE_URL", "GUARDRAILS_API_KEY")
|
||||
}
|
||||
with (
|
||||
patch("guardrails.api_client.Client"),
|
||||
patch("guardrails.api_client.AsyncClient"),
|
||||
):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
client = GuardrailsApiClient()
|
||||
|
||||
assert client.base_url == "http://localhost:8000"
|
||||
assert client.api_key == "x-guardrailsai-api-key"
|
||||
|
||||
def test_init_explicit_params_override_env(self):
|
||||
with (
|
||||
patch("guardrails.api_client.Client"),
|
||||
patch("guardrails.api_client.AsyncClient"),
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GUARDRAILS_BASE_URL": "http://env.com",
|
||||
"GUARDRAILS_API_KEY": "env-key",
|
||||
},
|
||||
):
|
||||
client = GuardrailsApiClient(
|
||||
base_url="http://custom.com", api_key="custom-key"
|
||||
)
|
||||
|
||||
assert client.base_url == "http://custom.com"
|
||||
assert client.api_key == "custom-key"
|
||||
|
||||
def test_init_partial_params_uses_env_key(self):
|
||||
env = {k: v for k, v in os.environ.items() if k != "GUARDRAILS_API_KEY"}
|
||||
with (
|
||||
patch("guardrails.api_client.Client"),
|
||||
patch("guardrails.api_client.AsyncClient"),
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{**env, "GUARDRAILS_API_KEY": "env-key"},
|
||||
clear=True,
|
||||
):
|
||||
client = GuardrailsApiClient(base_url="http://custom.com")
|
||||
|
||||
assert client.base_url == "http://custom.com"
|
||||
assert client.api_key == "env-key"
|
||||
|
||||
def test_init_creates_http_clients(self):
|
||||
client = GuardrailsApiClient()
|
||||
assert client.http_client is not None
|
||||
assert client.ahttp_client is not None
|
||||
|
||||
|
||||
# ─── upsert_guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpsertGuard:
|
||||
def test_upsert_guard_updates_existing_guard(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
guard_data = guard.model_dump.return_value
|
||||
existing_guard = make_guard(guard_id="existing-id-456")
|
||||
returned_guard = make_guard()
|
||||
|
||||
mc.client.fetch_guard = Mock(return_value=existing_guard)
|
||||
mc.http.put.return_value = mock_sync_response(guard_data)
|
||||
|
||||
with patch.object(Guard, "model_validate", return_value=returned_guard):
|
||||
result = mc.client.upsert_guard(guard)
|
||||
|
||||
mc.client.fetch_guard.assert_called_once_with(guard.name)
|
||||
mc.http.put.assert_called_once_with("/guards/existing-id-456", json=guard_data)
|
||||
mc.http.put.return_value.raise_for_status.assert_called_once()
|
||||
mc.http.post.assert_not_called()
|
||||
assert result == returned_guard
|
||||
|
||||
def test_upsert_guard_creates_new_guard_when_not_found(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
guard_data = guard.model_dump.return_value
|
||||
returned_guard = make_guard()
|
||||
|
||||
mc.client.fetch_guard = Mock(return_value=None)
|
||||
mc.http.post.return_value = mock_sync_response(guard_data)
|
||||
|
||||
with patch.object(Guard, "model_validate", return_value=returned_guard):
|
||||
result = mc.client.upsert_guard(guard)
|
||||
|
||||
mc.client.fetch_guard.assert_called_once_with(guard.name)
|
||||
mc.http.post.assert_called_once_with("/guards", json=guard_data)
|
||||
mc.http.put.assert_not_called()
|
||||
assert result == returned_guard
|
||||
|
||||
def test_upsert_guard_raises_on_http_error(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
guard.model_dump.return_value = {}
|
||||
existing_guard = make_guard(guard_id="existing-id-456")
|
||||
|
||||
mc.client.fetch_guard = Mock(return_value=existing_guard)
|
||||
mc.http.put.return_value = mock_sync_response(raise_error=make_http_error(500))
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
mc.client.upsert_guard(guard)
|
||||
|
||||
def test_aupsert_guard_updates_existing_guard(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
guard_data = guard.model_dump.return_value
|
||||
existing_guard = make_guard(guard_id="existing-id-456")
|
||||
returned_guard = make_guard()
|
||||
|
||||
mc.client.afetch_guard = AsyncMock(return_value=existing_guard)
|
||||
mc.ahttp.put = AsyncMock(return_value=mock_async_response(guard_data))
|
||||
|
||||
with patch.object(Guard, "model_validate", return_value=returned_guard):
|
||||
result = asyncio.run(mc.client.aupsert_guard(guard))
|
||||
|
||||
mc.client.afetch_guard.assert_called_once_with(guard.name)
|
||||
mc.ahttp.put.assert_called_once_with("/guards/existing-id-456", json=guard_data)
|
||||
mc.ahttp.post.assert_not_called()
|
||||
assert result == returned_guard
|
||||
|
||||
def test_aupsert_guard_creates_new_guard_when_not_found(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
guard_data = guard.model_dump.return_value
|
||||
returned_guard = make_guard()
|
||||
|
||||
mc.client.afetch_guard = AsyncMock(return_value=None)
|
||||
mc.ahttp.post = AsyncMock(return_value=mock_async_response(guard_data))
|
||||
|
||||
with patch.object(Guard, "model_validate", return_value=returned_guard):
|
||||
result = asyncio.run(mc.client.aupsert_guard(guard))
|
||||
|
||||
mc.client.afetch_guard.assert_called_once_with(guard.name)
|
||||
mc.ahttp.post.assert_called_once_with("/guards", json=guard_data)
|
||||
mc.ahttp.put.assert_not_called()
|
||||
assert result == returned_guard
|
||||
|
||||
def test_aupsert_guard_raises_on_http_error(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
guard.model_dump.return_value = {}
|
||||
existing_guard = make_guard(guard_id="existing-id-456")
|
||||
|
||||
mc.client.afetch_guard = AsyncMock(return_value=existing_guard)
|
||||
mc.ahttp.put = AsyncMock(
|
||||
return_value=mock_async_response(raise_error=make_http_error(500))
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
asyncio.run(mc.client.aupsert_guard(guard))
|
||||
|
||||
|
||||
# ─── fetch_guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFetchGuard:
|
||||
def test_fetch_guard_success(self):
|
||||
mc = make_client()
|
||||
guard_data = {"id": "g1", "name": "my-guard"}
|
||||
expected = make_guard()
|
||||
|
||||
mc.http.get.return_value = mock_sync_response([guard_data])
|
||||
|
||||
with patch.object(Guard, "model_validate", return_value=expected):
|
||||
result = mc.client.fetch_guard("my-guard")
|
||||
|
||||
mc.http.get.assert_called_once_with("/guards?name=my-guard")
|
||||
assert result == expected
|
||||
|
||||
def test_fetch_guard_returns_none_on_empty_list(self):
|
||||
mc = make_client()
|
||||
mc.http.get.return_value = mock_sync_response([])
|
||||
|
||||
result = mc.client.fetch_guard("missing-guard")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_fetch_guard_returns_none_on_exception(self):
|
||||
mc = make_client()
|
||||
mc.http.get.side_effect = Exception("connection error")
|
||||
|
||||
result = mc.client.fetch_guard("my-guard")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_fetch_guard_logs_error_on_exception(self):
|
||||
mc = make_client()
|
||||
mc.http.get.side_effect = Exception("API Error")
|
||||
|
||||
with patch("guardrails.api_client.logger") as mock_logger:
|
||||
result = mc.client.fetch_guard("my-guard")
|
||||
|
||||
assert result is None
|
||||
mock_logger.error.assert_called_once()
|
||||
assert "Error fetching guard my-guard" in mock_logger.error.call_args[0][0]
|
||||
assert "API Error" in mock_logger.error.call_args[0][0]
|
||||
|
||||
def test_afetch_guard_success(self):
|
||||
mc = make_client()
|
||||
guard_data = {"id": "g1", "name": "my-guard"}
|
||||
expected = make_guard()
|
||||
|
||||
mc.ahttp.get = AsyncMock(return_value=mock_async_response([guard_data]))
|
||||
|
||||
with patch.object(Guard, "model_validate", return_value=expected):
|
||||
result = asyncio.run(mc.client.afetch_guard("my-guard"))
|
||||
|
||||
mc.ahttp.get.assert_called_once_with("/guards?name=my-guard")
|
||||
assert result == expected
|
||||
|
||||
def test_afetch_guard_returns_none_on_empty_list(self):
|
||||
mc = make_client()
|
||||
mc.ahttp.get = AsyncMock(return_value=mock_async_response([]))
|
||||
|
||||
result = asyncio.run(mc.client.afetch_guard("missing-guard"))
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_afetch_guard_returns_none_on_exception(self):
|
||||
mc = make_client()
|
||||
mc.ahttp.get = AsyncMock(side_effect=Exception("connection error"))
|
||||
|
||||
result = asyncio.run(mc.client.afetch_guard("my-guard"))
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_afetch_guard_logs_error_on_exception(self):
|
||||
mc = make_client()
|
||||
mc.ahttp.get = AsyncMock(side_effect=Exception("API Error"))
|
||||
|
||||
with patch("guardrails.api_client.logger") as mock_logger:
|
||||
result = asyncio.run(mc.client.afetch_guard("my-guard"))
|
||||
|
||||
assert result is None
|
||||
mock_logger.error.assert_called_once()
|
||||
assert "Error fetching guard my-guard" in mock_logger.error.call_args[0][0]
|
||||
|
||||
|
||||
# ─── delete_guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteGuard:
|
||||
def test_delete_guard_success(self):
|
||||
mc = make_client()
|
||||
guard_data = {"id": "g1", "name": "my-guard"}
|
||||
fetched_guard = make_guard(guard_id="g1")
|
||||
deleted_guard = make_guard(guard_id="g1")
|
||||
|
||||
mc.http.get.return_value = mock_sync_response([guard_data])
|
||||
mc.http.delete.return_value = mock_sync_response(guard_data)
|
||||
|
||||
with patch.object(
|
||||
Guard, "model_validate", side_effect=[fetched_guard, deleted_guard]
|
||||
):
|
||||
result = mc.client.delete_guard("my-guard")
|
||||
|
||||
mc.http.delete.assert_called_once_with("/guards/g1")
|
||||
assert result == deleted_guard
|
||||
|
||||
def test_delete_guard_does_nothing_when_guard_not_found(self):
|
||||
mc = make_client()
|
||||
mc.http.get.return_value = mock_sync_response([])
|
||||
|
||||
result = mc.client.delete_guard("missing-guard")
|
||||
|
||||
mc.http.delete.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
def test_delete_guard_does_nothing_when_guard_has_no_id(self):
|
||||
mc = make_client()
|
||||
guard_data = {"name": "my-guard"}
|
||||
guard_no_id = make_guard()
|
||||
guard_no_id.id = None
|
||||
|
||||
mc.http.get.return_value = mock_sync_response([guard_data])
|
||||
|
||||
with patch.object(Guard, "model_validate", return_value=guard_no_id):
|
||||
result = mc.client.delete_guard("my-guard")
|
||||
|
||||
mc.http.delete.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
def test_adelete_guard_success(self):
|
||||
mc = make_client()
|
||||
guard_data = {"id": "g1", "name": "my-guard"}
|
||||
fetched_guard = make_guard(guard_id="g1")
|
||||
deleted_guard = make_guard(guard_id="g1")
|
||||
|
||||
mc.ahttp.get = AsyncMock(return_value=mock_async_response([guard_data]))
|
||||
mc.ahttp.delete = AsyncMock(return_value=mock_async_response(guard_data))
|
||||
|
||||
with patch.object(
|
||||
Guard, "model_validate", side_effect=[fetched_guard, deleted_guard]
|
||||
):
|
||||
result = asyncio.run(mc.client.adelete_guard("my-guard"))
|
||||
|
||||
mc.ahttp.delete.assert_called_once_with("/guards/g1")
|
||||
assert result == deleted_guard
|
||||
|
||||
def test_adelete_guard_does_nothing_when_guard_not_found(self):
|
||||
mc = make_client()
|
||||
mc.ahttp.get = AsyncMock(return_value=mock_async_response([]))
|
||||
|
||||
result = asyncio.run(mc.client.adelete_guard("missing-guard"))
|
||||
|
||||
mc.ahttp.delete.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ─── validate ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidate:
|
||||
def test_validate_success(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
outcome_data = {"callId": "c1"}
|
||||
outcome = Mock(spec=IValidationOutcome)
|
||||
|
||||
mc.http.post.return_value = mock_sync_response(outcome_data)
|
||||
|
||||
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
|
||||
result = mc.client.validate(
|
||||
guard,
|
||||
openai_api_key="oai-key",
|
||||
llm_output="hello",
|
||||
num_reasks=1,
|
||||
prompt_params={"key": "val"},
|
||||
)
|
||||
|
||||
assert result == outcome
|
||||
mc.http.post.assert_called_once_with(
|
||||
"/guards/guard-id-123/validate",
|
||||
json={
|
||||
"llm_output": "hello",
|
||||
"num_reasks": 1,
|
||||
"prompt_params": {"key": "val"},
|
||||
},
|
||||
headers={"x-openai-api-key": "oai-key"},
|
||||
)
|
||||
|
||||
def test_validate_uses_env_openai_key(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.http.post.return_value = mock_sync_response({})
|
||||
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-oai-key"}):
|
||||
with patch.object(
|
||||
IValidationOutcome, "model_validate", return_value=Mock()
|
||||
):
|
||||
mc.client.validate(guard)
|
||||
|
||||
call_kwargs = mc.http.post.call_args[1]
|
||||
assert call_kwargs["headers"] == {"x-openai-api-key": "env-oai-key"}
|
||||
|
||||
def test_validate_omits_openai_header_when_no_key(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.http.post.return_value = mock_sync_response({})
|
||||
|
||||
env = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
with patch.object(
|
||||
IValidationOutcome, "model_validate", return_value=Mock()
|
||||
):
|
||||
mc.client.validate(guard)
|
||||
|
||||
call_kwargs = mc.http.post.call_args[1]
|
||||
assert call_kwargs["headers"] == {}
|
||||
|
||||
def test_validate_raises_validation_error_on_400(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.http.post.return_value = mock_sync_response(raise_error=make_http_error(400))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
mc.client.validate(guard)
|
||||
|
||||
def test_validate_returns_none_on_non_400_http_error(self):
|
||||
# Non-400 HTTPStatusErrors are caught but not re-raised per current impl
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.http.post.return_value = mock_sync_response(raise_error=make_http_error(500))
|
||||
|
||||
result = mc.client.validate(guard)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_validate_passes_kwargs_in_body(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.http.post.return_value = mock_sync_response({})
|
||||
|
||||
with patch.object(IValidationOutcome, "model_validate", return_value=Mock()):
|
||||
mc.client.validate(guard, extra_field="extra_value")
|
||||
|
||||
call_json = mc.http.post.call_args[1]["json"]
|
||||
assert call_json["extra_field"] == "extra_value"
|
||||
|
||||
def test_avalidate_success(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
outcome_data = {"callId": "c1"}
|
||||
outcome = Mock(spec=IValidationOutcome)
|
||||
|
||||
mc.ahttp.post = AsyncMock(return_value=mock_async_response(outcome_data))
|
||||
|
||||
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
|
||||
result = asyncio.run(
|
||||
mc.client.avalidate(
|
||||
guard,
|
||||
openai_api_key="oai-key",
|
||||
llm_output="hello",
|
||||
num_reasks=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == outcome
|
||||
mc.ahttp.post.assert_called_once_with(
|
||||
"/guards/guard-id-123/validate",
|
||||
json={
|
||||
"llm_output": "hello",
|
||||
"num_reasks": 2,
|
||||
"prompt_params": None,
|
||||
},
|
||||
headers={"x-openai-api-key": "oai-key"},
|
||||
)
|
||||
|
||||
def test_avalidate_raises_validation_error_on_400(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.ahttp.post = AsyncMock(
|
||||
return_value=mock_async_response(raise_error=make_http_error(400))
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
asyncio.run(mc.client.avalidate(guard))
|
||||
|
||||
def test_avalidate_returns_none_on_non_400_http_error(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.ahttp.post = AsyncMock(
|
||||
return_value=mock_async_response(raise_error=make_http_error(500))
|
||||
)
|
||||
|
||||
result = asyncio.run(mc.client.avalidate(guard))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ─── stream_validate ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStreamValidate:
|
||||
def test_stream_validate_yields_outcomes(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
outcome = Mock(spec=IValidationOutcome)
|
||||
|
||||
chunks = [
|
||||
make_sse_chunk({"callId": "c1", "validationPassed": True}),
|
||||
make_sse_chunk({"callId": "c2", "validationPassed": False}),
|
||||
]
|
||||
mc.http.stream.return_value = make_stream_ctx(chunks)
|
||||
|
||||
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
|
||||
results = list(mc.client.stream_validate(guard, openai_api_key="oai-key"))
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(r is outcome for r in results)
|
||||
call_args = mc.http.stream.call_args
|
||||
assert call_args[0] == ("POST", "/guards/guard-id-123/validate")
|
||||
assert call_args[1]["json"]["stream"] is True
|
||||
|
||||
def test_stream_validate_skips_empty_chunks(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
outcome = Mock(spec=IValidationOutcome)
|
||||
|
||||
chunks = ["", " ", make_sse_chunk({"callId": "c1"}), ""]
|
||||
mc.http.stream.return_value = make_stream_ctx(chunks)
|
||||
|
||||
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
|
||||
results = list(mc.client.stream_validate(guard))
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
def test_stream_validate_raises_on_error_in_chunk(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
|
||||
chunks = [make_sse_chunk({"error": {"message": "Validation error occurred"}})]
|
||||
mc.http.stream.return_value = make_stream_ctx(chunks)
|
||||
|
||||
with pytest.raises(Exception, match="Validation error occurred"):
|
||||
list(mc.client.stream_validate(guard))
|
||||
|
||||
def test_stream_validate_raises_on_non_success_response(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
|
||||
ctx = make_stream_ctx([], is_success=False, raise_on_fail=make_http_error(500))
|
||||
mc.http.stream.return_value = ctx
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
list(mc.client.stream_validate(guard))
|
||||
|
||||
def test_stream_validate_passes_llm_output_and_params(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.http.stream.return_value = make_stream_ctx([])
|
||||
|
||||
list(
|
||||
mc.client.stream_validate(
|
||||
guard,
|
||||
llm_output="test output",
|
||||
num_reasks=3,
|
||||
prompt_params={"k": "v"},
|
||||
)
|
||||
)
|
||||
|
||||
call_json = mc.http.stream.call_args[1]["json"]
|
||||
assert call_json["llm_output"] == "test output"
|
||||
assert call_json["num_reasks"] == 3
|
||||
assert call_json["prompt_params"] == {"k": "v"}
|
||||
assert call_json["stream"] is True
|
||||
|
||||
def test_stream_validate_uses_env_openai_key(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
mc.http.stream.return_value = make_stream_ctx([])
|
||||
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-oai-key"}):
|
||||
list(mc.client.stream_validate(guard))
|
||||
|
||||
mc.http.stream.assert_called_once()
|
||||
|
||||
def test_astream_validate_yields_outcomes(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
outcome = Mock(spec=IValidationOutcome)
|
||||
|
||||
chunks = [
|
||||
make_sse_chunk({"callId": "c1"}),
|
||||
make_sse_chunk({"callId": "c2"}),
|
||||
]
|
||||
# ahttp is AsyncMock, so .stream() returns a coroutine by default.
|
||||
# Override with a regular Mock so it returns the async context manager.
|
||||
mock_stream = Mock(return_value=make_async_stream_ctx(chunks))
|
||||
mc.ahttp.stream = mock_stream
|
||||
|
||||
async def run():
|
||||
return [
|
||||
r
|
||||
async for r in mc.client.astream_validate(
|
||||
guard, openai_api_key="oai-key"
|
||||
)
|
||||
]
|
||||
|
||||
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
|
||||
results = asyncio.run(run())
|
||||
|
||||
assert len(results) == 2
|
||||
assert all(r is outcome for r in results)
|
||||
call_args = mock_stream.call_args
|
||||
assert call_args[0] == ("POST", "/guards/guard-id-123/validate")
|
||||
assert call_args[1]["json"]["stream"] is True
|
||||
|
||||
def test_astream_validate_skips_empty_chunks(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
outcome = Mock(spec=IValidationOutcome)
|
||||
|
||||
chunks = ["", make_sse_chunk({"callId": "c1"}), " "]
|
||||
mc.ahttp.stream = Mock(return_value=make_async_stream_ctx(chunks))
|
||||
|
||||
async def run():
|
||||
return [r async for r in mc.client.astream_validate(guard)]
|
||||
|
||||
with patch.object(IValidationOutcome, "model_validate", return_value=outcome):
|
||||
results = asyncio.run(run())
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
def test_astream_validate_raises_on_error_in_chunk(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
|
||||
chunks = [make_sse_chunk({"error": {"message": "Stream error"}})]
|
||||
mc.ahttp.stream = Mock(return_value=make_async_stream_ctx(chunks))
|
||||
|
||||
async def run():
|
||||
return [r async for r in mc.client.astream_validate(guard)]
|
||||
|
||||
with pytest.raises(Exception, match="Stream error"):
|
||||
asyncio.run(run())
|
||||
|
||||
def test_astream_validate_raises_on_non_success_response(self):
|
||||
mc = make_client()
|
||||
guard = make_guard()
|
||||
|
||||
ctx = make_async_stream_ctx(
|
||||
[], is_success=False, raise_on_fail=make_http_error(500)
|
||||
)
|
||||
mc.ahttp.stream = Mock(return_value=ctx)
|
||||
|
||||
async def run():
|
||||
return [r async for r in mc.client.astream_validate(guard)]
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ─── get_history ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetHistory:
|
||||
def test_get_history_success(self):
|
||||
mc = make_client()
|
||||
history_data = {"calls": [{"callId": "c1"}]}
|
||||
mc.http.get.return_value = mock_sync_response(history_data)
|
||||
|
||||
result = mc.client.get_history("g1", "c1")
|
||||
|
||||
mc.http.get.assert_called_once_with("/guards/g1/history/c1")
|
||||
assert result == history_data
|
||||
|
||||
def test_get_history_uses_guard_id_not_name(self):
|
||||
mc = make_client()
|
||||
mc.http.get.return_value = mock_sync_response({})
|
||||
|
||||
mc.client.get_history("guard-uuid-123", "call-uuid-456")
|
||||
|
||||
mc.http.get.assert_called_once_with(
|
||||
"/guards/guard-uuid-123/history/call-uuid-456"
|
||||
)
|
||||
|
||||
def test_get_history_raises_on_http_error(self):
|
||||
mc = make_client()
|
||||
mc.http.get.return_value = mock_sync_response(raise_error=make_http_error(404))
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
mc.client.get_history("g1", "c1")
|
||||
|
||||
def test_get_history_returns_raw_body(self):
|
||||
mc = make_client()
|
||||
raw_body = [{"id": "1"}, {"id": "2"}]
|
||||
mc.http.get.return_value = mock_sync_response(raw_body)
|
||||
|
||||
result = mc.client.get_history("g1", "c1")
|
||||
|
||||
assert result == raw_body
|
||||
Reference in New Issue
Block a user