袁聪的第三次提交,API接口完善

This commit is contained in:
2026-09-12 12:20:01 +08:00
parent 0a78a9df76
commit 91f5373cfb
20 changed files with 524 additions and 73 deletions
@@ -59,6 +59,8 @@ def test_normalize_amount_keeps_original_unit_semantics() -> None:
assert normalize_amount_yuan("2.50", "万元") == Decimal("25000.00")
assert normalize_amount_yuan("2.50", "元") == Decimal("2.50")
assert normalize_amount_yuan("20,000.00万元", "万元") == Decimal("200000000.00")
assert normalize_amount_yuan("人民币5,000.00元", "元") == Decimal("5000.00")
assert normalize_amount_yuan("CNY 5,000.00", "元") == Decimal("5000.00")
assert normalize_amount_yuan("2.50", "美元") is None
@@ -167,6 +167,8 @@ def _settings(**updates: object) -> Settings:
"offsite_smtp_username": "15273589815@163.com",
"offsite_smtp_password": "test-auth-code",
"offsite_smtp_sender": "15273589815@163.com",
"offsite_smtp_enabled": False,
"offsite_smtp_dry_run": True,
}
values.update(updates)
return Settings(**values)
@@ -18,7 +18,7 @@ def _valid_inputs() -> dict[str, object]:
},
"manager_info": {
"manager_name": "张三",
"management_company": "奶龙基金管理有限公司",
"management_company": "南方基金管理有限公司",
"registration_code": "P10000001",
},
"team_info": {"team_description": "具备完整投研分工"},
+83 -26
View File
@@ -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"