122 lines
4.1 KiB
Python
122 lines
4.1 KiB
Python
from datetime import datetime
|
|||
|
|
from decimal import Decimal
|
||
|
|
from types import MappingProxyType
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.api.schemas.risk import RiskAlertPageQuery, RiskEvidencePageQuery
|
||
|
|
from app.core.contracts import RequestContext
|
||
|
|
from app.core.errors import GenericResourceNotFoundError, InvalidCursorError
|
||
|
|
from app.core.risk_cursor import decode_offset_cursor, encode_offset_cursor
|
||
|
|
from app.repository.fund_query_repository import CustomerScope, FundPage, FundRecord
|
||
|
|
from app.service.risk_query_service import RiskQueryService, scope_from_context
|
||
|
|
|
||
|
|
|
||
|
|
class FakeRepository:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.page = FundPage(
|
||
|
|
entity="risk_alert",
|
||
|
|
items=(
|
||
|
|
FundRecord(
|
||
|
|
entity="risk_alert",
|
||
|
|
values=MappingProxyType(
|
||
|
|
{
|
||
|
|
"alert_no": "ALERT-001",
|
||
|
|
"customer_id": 9,
|
||
|
|
"risk_level": "高",
|
||
|
|
"amount": Decimal("100.00"),
|
||
|
|
"created_at": datetime(2026, 9, 10, 8, 0),
|
||
|
|
"rule_codes": ("RW-007",),
|
||
|
|
}
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
limit=5,
|
||
|
|
offset=0,
|
||
|
|
next_offset=5,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def overview(self) -> dict:
|
||
|
|
return {
|
||
|
|
"total": 2,
|
||
|
|
"levels": {"低": 1, "中": 0, "高": 1},
|
||
|
|
"pending": 1,
|
||
|
|
"overdue": 0,
|
||
|
|
"high_priority": list(self.page.items),
|
||
|
|
}
|
||
|
|
|
||
|
|
async def list_alerts(self, **_kwargs) -> FundPage:
|
||
|
|
return self.page
|
||
|
|
|
||
|
|
async def get_alert_detail(self, _alert_no: str) -> FundRecord | None:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def context(**updates) -> RequestContext:
|
||
|
|
values = {
|
||
|
|
"user_id": "990000002",
|
||
|
|
"trace_id": "trace",
|
||
|
|
"permissions": ("risk:alert:read",),
|
||
|
|
"data_scope": "all",
|
||
|
|
}
|
||
|
|
values.update(updates)
|
||
|
|
return RequestContext(**values)
|
||
|
|
|
||
|
|
|
||
|
|
def test_cursor_round_trip_and_invalid_values() -> None:
|
||
|
|
assert decode_offset_cursor(encode_offset_cursor(20)) == 20
|
||
|
|
assert decode_offset_cursor(None) == 0
|
||
|
|
with pytest.raises(InvalidCursorError):
|
||
|
|
decode_offset_cursor("not-a-cursor")
|
||
|
|
with pytest.raises(InvalidCursorError):
|
||
|
|
decode_offset_cursor(encode_offset_cursor(1) + "x")
|
||
|
|
|
||
|
|
|
||
|
|
def test_query_schema_enforces_business_page_sizes() -> None:
|
||
|
|
assert RiskAlertPageQuery(limit=5).limit == 5
|
||
|
|
assert RiskEvidencePageQuery(limit=10).limit == 10
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
RiskAlertPageQuery(limit=6)
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
RiskEvidencePageQuery(limit=11)
|
||
|
|
|
||
|
|
|
||
|
|
def test_scope_from_context_is_fail_closed() -> None:
|
||
|
|
assert scope_from_context(context()).is_unrestricted
|
||
|
|
assert scope_from_context(context(data_scope="self")).is_denied
|
||
|
|
scope = scope_from_context(
|
||
|
|
context(data_scope="own_customers", customer_ids=("9", "10"))
|
||
|
|
)
|
||
|
|
assert scope == CustomerScope.for_customers({9, 10})
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_overview_maps_levels_and_serializes_high_priority() -> None:
|
||
|
|
service = RiskQueryService(None, repository=FakeRepository())
|
||
|
|
|
||
|
|
result = await service.overview(context())
|
||
|
|
|
||
|
|
assert result["levels"] == {"高风险": 1, "中风险": 0, "低风险": 1}
|
||
|
|
assert result["high_priority"][0]["customer_id"] == "9"
|
||
|
|
assert result["high_priority"][0]["amount"] == "100.00"
|
||
|
|
assert result["high_priority"][0]["created_at"] == "2026-09-10T08:00:00Z"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_alert_page_returns_opaque_cursor() -> None:
|
||
|
|
service = RiskQueryService(None, repository=FakeRepository())
|
||
|
|
|
||
|
|
result = await service.list_alerts(context(), RiskAlertPageQuery(limit=5))
|
||
|
|
|
||
|
|
assert result["has_more"] is True
|
||
|
|
assert decode_offset_cursor(result["next_cursor"]) == 5
|
||
|
|
assert result["items"][0]["rule_codes"] == ["RW-007"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_missing_alert_detail_is_hidden() -> None:
|
||
|
|
service = RiskQueryService(None, repository=FakeRepository())
|
||
|
|
|
||
|
|
with pytest.raises(GenericResourceNotFoundError):
|
||
|
|
await service.get_alert_detail(context(), "ALERT-NOT-FOUND")
|