|
|
|
@@ -1,65 +1,115 @@
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
|
|
|
|
from app.core.contracts import RequestContext
|
|
|
|
|
from app.service.suitability_service import (
|
|
|
|
|
SuitabilityRequest,
|
|
|
|
|
RiskAuthorityProfile,
|
|
|
|
|
SuitabilityService,
|
|
|
|
|
SuitabilityToolInput,
|
|
|
|
|
suitability_tool_handler,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request(**overrides: object) -> SuitabilityRequest:
|
|
|
|
|
def _request(**overrides: object) -> SuitabilityToolInput:
|
|
|
|
|
values: dict[str, object] = {
|
|
|
|
|
"customer_risk_level": 3,
|
|
|
|
|
"customer_id": "7",
|
|
|
|
|
"product_risk_level": 3,
|
|
|
|
|
"product_requires_disclosure": True,
|
|
|
|
|
}
|
|
|
|
|
values.update(overrides)
|
|
|
|
|
return SuitabilityRequest.model_validate(values)
|
|
|
|
|
return SuitabilityToolInput.model_validate(values)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _context() -> RequestContext:
|
|
|
|
|
return RequestContext(user_id="7", trace_id="trace-suitability")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _profile(
|
|
|
|
|
customer_risk_level: int | None = 3,
|
|
|
|
|
*,
|
|
|
|
|
valid_until: datetime | None = None,
|
|
|
|
|
authority_reason: str = "AUTHORITY_OK",
|
|
|
|
|
) -> RiskAuthorityProfile:
|
|
|
|
|
return RiskAuthorityProfile(
|
|
|
|
|
customer_id="7",
|
|
|
|
|
customer_risk_level=customer_risk_level,
|
|
|
|
|
valid_until=valid_until or datetime.now(UTC) + timedelta(days=30),
|
|
|
|
|
authority_reason=authority_reason, # type: ignore[arg-type]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("customer", "product", "allowed"),
|
|
|
|
|
[(1, 1, True), (1, 2, False), (3, 2, True), (5, 5, True)],
|
|
|
|
|
)
|
|
|
|
|
def test_risk_level_boundary(customer: int, product: int, allowed: bool) -> None:
|
|
|
|
|
decision = SuitabilityService().evaluate(
|
|
|
|
|
request(customer_risk_level=customer, product_risk_level=product)
|
|
|
|
|
async def test_risk_level_boundary(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
customer: int,
|
|
|
|
|
product: int,
|
|
|
|
|
allowed: bool,
|
|
|
|
|
) -> None:
|
|
|
|
|
service = SuitabilityService()
|
|
|
|
|
|
|
|
|
|
async def load_profile(_customer_id: str) -> RiskAuthorityProfile:
|
|
|
|
|
return _profile(customer)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_load_authority_profile", load_profile)
|
|
|
|
|
decision = await service.evaluate(
|
|
|
|
|
_request(product_risk_level=product), _context()
|
|
|
|
|
)
|
|
|
|
|
assert decision.allowed is allowed
|
|
|
|
|
assert decision.reason_code == ("SUITABLE" if allowed else "RISK_LEVEL_MISMATCH")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_expired_assessment_is_denied() -> None:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_expired_assessment_is_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
now = datetime(2026, 9, 9, tzinfo=UTC)
|
|
|
|
|
decision = SuitabilityService().evaluate(
|
|
|
|
|
request(assessment_expires_at=now - timedelta(seconds=1)), now=now
|
|
|
|
|
)
|
|
|
|
|
service = SuitabilityService()
|
|
|
|
|
|
|
|
|
|
async def load_profile(_customer_id: str) -> RiskAuthorityProfile:
|
|
|
|
|
return _profile(valid_until=now - timedelta(seconds=1))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_load_authority_profile", load_profile)
|
|
|
|
|
decision = await service.evaluate(_request(), _context(), now=now)
|
|
|
|
|
assert decision.allowed is False
|
|
|
|
|
assert decision.reason_code == "ASSESSMENT_EXPIRED"
|
|
|
|
|
assert decision.requires_recording is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disclosure_requires_confirmation_and_recording() -> None:
|
|
|
|
|
decision = SuitabilityService().evaluate(
|
|
|
|
|
request(product_requires_disclosure=True, requires_confirmation=False)
|
|
|
|
|
)
|
|
|
|
|
assert decision.allowed is True
|
|
|
|
|
assert decision.required_disclosure is True
|
|
|
|
|
assert decision.requires_confirmation is True
|
|
|
|
|
assert decision.requires_recording is True
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_missing_assessment_is_denied(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
service = SuitabilityService()
|
|
|
|
|
|
|
|
|
|
async def load_profile(_customer_id: str) -> RiskAuthorityProfile:
|
|
|
|
|
return _profile(None, authority_reason="ASSESSMENT_MISSING")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_load_authority_profile", load_profile)
|
|
|
|
|
decision = await service.evaluate(_request(), _context())
|
|
|
|
|
assert decision.allowed is False
|
|
|
|
|
assert decision.reason_code == "ASSESSMENT_MISSING"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_timezone_is_rejected() -> None:
|
|
|
|
|
with pytest.raises(ValueError, match="timezone"):
|
|
|
|
|
request(assessment_expires_at=datetime(2026, 9, 9))
|
|
|
|
|
def test_tool_input_rejects_client_supplied_authority_fields() -> None:
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
SuitabilityToolInput(
|
|
|
|
|
customer_id="7",
|
|
|
|
|
customer_risk_level=3,
|
|
|
|
|
product_risk_level=3,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_input_rejects_invalid_customer_id() -> None:
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
SuitabilityToolInput(customer_id="customer-7", product_risk_level=3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_tool_input_uses_same_rules(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
async def test_tool_handler_uses_current_service_entrypoint(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
class DummySession:
|
|
|
|
|
def add(self, _item: object) -> None:
|
|
|
|
|
pass
|
|
|
|
@@ -73,12 +123,19 @@ async def test_tool_input_uses_same_rules(monkeypatch: pytest.MonkeyPatch) -> No
|
|
|
|
|
def begin(self) -> "DummySession":
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
service = SuitabilityService(session_factory=lambda: DummySession())
|
|
|
|
|
|
|
|
|
|
async def load_profile(_customer_id: str) -> RiskAuthorityProfile:
|
|
|
|
|
return _profile(1)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_load_authority_profile", load_profile)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"app.service.suitability_service.SessionFactory", lambda: DummySession()
|
|
|
|
|
"app.service.suitability_service.SuitabilityService",
|
|
|
|
|
lambda: service,
|
|
|
|
|
)
|
|
|
|
|
context = RequestContext(user_id="7", trace_id="trace-suitability")
|
|
|
|
|
result = await suitability_tool_handler(
|
|
|
|
|
SuitabilityToolInput(customer_risk_level=1, product_risk_level=2), context
|
|
|
|
|
SuitabilityToolInput(customer_id="7", product_risk_level=2),
|
|
|
|
|
_context(),
|
|
|
|
|
)
|
|
|
|
|
assert result["allowed"] is False
|
|
|
|
|
assert result["reason_code"] == "RISK_LEVEL_MISMATCH"
|
|
|
|
|