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 cursor_binding, 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: binding = cursor_binding(user_id="990000002", filters={"keyword": None}) assert ( decode_offset_cursor(encode_offset_cursor(20, binding=binding), binding=binding) == 20 ) assert decode_offset_cursor(None, binding=binding) == 0 with pytest.raises(InvalidCursorError): decode_offset_cursor("not-a-cursor", binding=binding) with pytest.raises(InvalidCursorError): decode_offset_cursor(encode_offset_cursor(1, binding=binding) + "x", binding=binding) 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()) query = RiskAlertPageQuery(limit=5) result = await service.list_alerts(context(), query) assert result["has_more"] is True binding = RiskQueryService._binding(context(), query) assert decode_offset_cursor(result["next_cursor"], binding=binding) == 5 assert result["items"][0]["rule_codes"] == ["RW-007"] def test_cursor_is_bound_to_user_and_filters() -> None: """§3.8:游标绑定用户与查询条件,换个人或换个筛选条件都必须失效。""" query = RiskAlertPageQuery(limit=5) raw = encode_offset_cursor(5, binding=RiskQueryService._binding(context(), query)) assert decode_offset_cursor(raw, binding=RiskQueryService._binding(context(), query)) == 5 with pytest.raises(InvalidCursorError): decode_offset_cursor( raw, binding=RiskQueryService._binding(context(user_id="990000003"), query), ) with pytest.raises(InvalidCursorError): other_filter = RiskAlertPageQuery(limit=5, keyword="张三") decode_offset_cursor( raw, binding=RiskQueryService._binding(context(), other_filter), ) # data_scope 收紧后旧游标同样失效 with pytest.raises(InvalidCursorError): decode_offset_cursor( raw, binding=RiskQueryService._binding( context(data_scope="own_customers", customer_ids=("9",)), query ), ) def test_cursor_ignores_page_size_and_other_filters_do_not_collide() -> None: """翻页时改 limit 不该让游标失效;不同证据类型之间不能互相串用游标。""" base = RiskEvidencePageQuery(limit=10) binding = RiskQueryService._binding(context(), base, "customers") again = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "customers") other = RiskQueryService._binding(context(), RiskEvidencePageQuery(limit=10), "products") assert binding == again assert binding != other @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")