Files
group_fqcd_jr/tests/integration/test_offsite_rule_results.py

327 lines
12 KiB
Python

"""单据规则判定结果查询与重新判定接口的集成测试。
覆盖:申购三条规则与赎回两条规则(含大额赎回)的结论返回、汇总统计、
用已落库查询结果重新判定后结论正确、没有查询记录时按"无法判断"并给出原因、
单据不存在、越权,以及两个动作都必须留下审计记录。
统一在单事件循环内调用应用(httpx.ASGITransport),避免连接池里的连接被
跨事件循环复用。
"""
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from uuid import uuid4
import httpx
import pytest
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.dependencies.auth import build_request_context
from app.api.dependencies.database import get_session
from app.core.contracts import RequestContext
from app.infrastructure.db import SessionFactory, engine
from app.main import app
from app.model.audit import InteractionAudit
from app.model.offsite_fund import (
OffsiteFundAttachment,
OffsiteFundDocument,
OffsiteQueryRecord,
OffsiteRuleResult,
)
RESULTS_PATH = "/api/v1/offsite-fund/documents/{task_id}/rule-results"
RECALC_PATH = "/api/v1/offsite-fund/documents/{task_id}/rule-results/recalculations"
TRACE_ID = ""
@pytest.fixture(autouse=True)
async def _dispose_engine_after_test() -> AsyncIterator[None]:
"""用例结束后释放连接池,避免连接绑定在已关闭的事件循环上。"""
yield
await engine.dispose()
async def _override_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
def _install_context(roles: tuple[str, ...], permissions: tuple[str, ...]) -> None:
async def override_context() -> RequestContext:
return RequestContext(
user_id="990911000",
trace_id=TRACE_ID,
roles=roles,
permissions=permissions,
data_scope="all",
)
app.dependency_overrides[build_request_context] = override_context
app.dependency_overrides[get_session] = _override_session
async def _get_results(task_id: str) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get(RESULTS_PATH.format(task_id=task_id))
async def _recalculate(task_id: str) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.post(
RECALC_PATH.format(task_id=task_id), json={"operator_id": "990911000"}
)
async def _seed_document(
document_type: str,
extracted_fields: dict[str, object],
document_status: str = "planned",
) -> tuple[str, str]:
"""写入单据与原始附件识别字段,返回 (task_id, attachment_id)。"""
task_id = f"T{uuid4().hex[:14]}"
attachment_id = f"A{uuid4().hex[:14]}"
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
session.add(OffsiteFundDocument(
task_id=task_id,
mail_id=f"M{uuid4().hex[:12]}",
attachment_id=attachment_id,
document_type=document_type,
fund_code="15911",
account_identifier="10001",
status=document_status,
operator_decision="未处理",
created_at=now,
updated_at=now,
))
session.add(OffsiteFundAttachment(
attachment_id=attachment_id,
mail_id=f"M{uuid4().hex[:12]}",
filename="规则判定测试附件.pdf",
file_hash=uuid4().hex,
media_type="application/pdf",
size_bytes=1024,
document_type=document_type,
original_file_path="mock/规则判定测试附件.pdf",
ocr_text="规则判定测试",
extracted_fields=extracted_fields,
field_confidence={},
page_evidence={},
status="recognized",
created_at=now,
))
return task_id, attachment_id
async def _seed_query_record(
task_id: str, rule_code: str, rows: list[dict[str, object]]
) -> None:
now = datetime.now(UTC).replace(tzinfo=None)
async with SessionFactory() as session, session.begin():
session.add(OffsiteQueryRecord(
task_id=task_id,
rule_code=rule_code,
natural_language_request=f"{rule_code} 的只读核对查询",
script_path="nl2sql_yc.py",
result_summary={"status": "success", "data": {"total": len(rows), "rows": rows}},
status="success",
error_message=None,
created_at=now,
))
async def _count_audit(action_type: str) -> int:
async with SessionFactory() as session:
rows = await session.execute(select(InteractionAudit).where(
InteractionAudit.action_type == action_type,
InteractionAudit.detail["trace_id"].as_string() == TRACE_ID,
))
return len(rows.scalars().all())
async def _cleanup(task_id: str) -> None:
async with SessionFactory() as session, session.begin():
if TRACE_ID:
await session.execute(delete(InteractionAudit).where(
InteractionAudit.detail["trace_id"].as_string() == TRACE_ID
))
if task_id:
await session.execute(delete(OffsiteQueryRecord).where(
OffsiteQueryRecord.task_id == task_id
))
await session.execute(delete(OffsiteRuleResult).where(
OffsiteRuleResult.task_id == task_id
))
await session.execute(delete(OffsiteFundAttachment).where(
OffsiteFundAttachment.mail_id.in_(
select(OffsiteFundDocument.mail_id).where(
OffsiteFundDocument.task_id == task_id
)
)
))
await session.execute(delete(OffsiteFundDocument).where(
OffsiteFundDocument.task_id == task_id
))
@pytest.mark.integration
async def test_subscription_rules_return_three_results() -> None:
global TRACE_ID
TRACE_ID = f"trace-rule-results-{uuid4()}"
task_id = ""
try:
task_id, _ = await _seed_document(
"subscription", {"申购金额": "200000000", "金额单位": "元"}
)
await _seed_query_record(task_id, "subscription_holding_ratio", [{
"nav": "1.250000",
"total_fund_shares": "10000000000.0000",
"total_quantity": "100000000.0000",
}])
await _seed_query_record(task_id, "subscription_single_share_limit", [{
"nav": "1.250000",
"total_fund_shares": "10000000000.0000",
}])
_install_context(("operator",), ("offsite:read", "offsite:write"))
recalculated = await _recalculate(task_id)
assert recalculated.status_code == 200
assert recalculated.json()["code"] == 0
data = recalculated.json()["data"]
assert [item["rule_name"] for item in data["rules"]] == [
"申购最低金额", "申购后单一投资者持有比例", "申购单笔份额上限",
]
assert data["summary"] == {"total": 3, "normal": 3, "abnormal": 0, "unknown": 0}
assert all(item["status"] == "success" for item in data["query_snapshot"])
queried = await _get_results(task_id)
assert queried.json()["data"]["summary"]["normal"] == 3
assert queried.json()["data"]["rules"][1]["database_value"]["最新净值"] == "1.250000"
assert await _count_audit("offsite.rule_results_recalculated") == 1
assert await _count_audit("offsite.rule_results_viewed") == 1
finally:
await _cleanup(task_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_redemption_rules_flag_large_redemption() -> None:
"""赎回 30% 超过 20% 阈值时必须判定为异常,重新判定接口要能给出结论。"""
global TRACE_ID
TRACE_ID = f"trace-rule-results-{uuid4()}"
task_id = ""
try:
task_id, _ = await _seed_document("redemption", {"赎回份额": "300000"})
await _seed_query_record(task_id, "redemption_large_ratio", [{
"total_fund_shares": "1000000",
}])
await _seed_query_record(task_id, "redemption_available_quantity", [{
"available_quantity": "500000",
}])
_install_context(("operator",), ("offsite:write",))
recalculated = await _recalculate(task_id)
data = recalculated.json()["data"]
rules = {item["rule_code"]: item for item in data["rules"]}
assert rules["redemption_large_ratio"]["result"] == "异常"
assert rules["redemption_large_ratio"]["calculation"]["赎回比例"] == "0.3"
assert rules["redemption_available_quantity"]["result"] == "正常"
assert data["summary"] == {"total": 2, "normal": 1, "abnormal": 1, "unknown": 0}
finally:
await _cleanup(task_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_recalculation_without_query_records_marks_unknown() -> None:
"""没有查询记录时不得猜测结论,必须给出无法判断和具体原因。"""
global TRACE_ID
TRACE_ID = f"trace-rule-results-{uuid4()}"
task_id = ""
try:
task_id, _ = await _seed_document("redemption", {"赎回份额": "300000"})
_install_context(("operator",), ("offsite:write",))
recalculated = await _recalculate(task_id)
data = recalculated.json()["data"]
assert data["summary"] == {"total": 2, "normal": 0, "abnormal": 0, "unknown": 2}
assert data["document_status"] == "query_failed"
assert all(
item["reason"] == "尚未执行该规则的 NL2SQL 查询"
for item in data["query_snapshot"]
)
finally:
await _cleanup(task_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_recalculation_keeps_recognition_status() -> None:
"""识别异常属于识别阶段状态,重新判定不得把它改成查询失败。"""
global TRACE_ID
TRACE_ID = f"trace-rule-results-{uuid4()}"
task_id = ""
try:
task_id, _ = await _seed_document(
"subscription",
{"申购金额": "200000000", "金额单位": "元"},
document_status="recognition_exception",
)
_install_context(("operator",), ("offsite:write",))
recalculated = await _recalculate(task_id)
data = recalculated.json()["data"]
assert data["document_status"] == "recognition_exception"
# 金额已识别,所以最低金额规则仍可判定;两条依赖查询的规则必须为无法判断。
assert data["summary"] == {"total": 3, "normal": 1, "abnormal": 0, "unknown": 2}
finally:
await _cleanup(task_id)
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_rule_results_unknown_document_returns_not_found() -> None:
global TRACE_ID
TRACE_ID = f"trace-rule-results-{uuid4()}"
try:
_install_context(("operator",), ("offsite:read", "offsite:write"))
queried = await _get_results("T000000000000")
recalculated = await _recalculate("T000000000000")
assert queried.json()["code"] == 404
assert recalculated.json()["code"] == 404
assert recalculated.json()["message"] == "单据不存在"
finally:
app.dependency_overrides.clear()
TRACE_ID = ""
@pytest.mark.integration
async def test_rule_results_requires_offsite_permission() -> None:
global TRACE_ID
TRACE_ID = f"trace-rule-results-{uuid4()}"
task_id = ""
try:
task_id, _ = await _seed_document("redemption", {"赎回份额": "300000"})
_install_context(("operator",), ())
read_denied = await _get_results(task_id)
recalculate_denied = await _recalculate(task_id)
_install_context(("customer",), ("offsite:read", "offsite:write"))
wrong_role = await _get_results(task_id)
assert read_denied.json()["code"] == 403
assert recalculate_denied.json()["code"] == 403
assert wrong_role.json()["code"] == 403
finally:
await _cleanup(task_id)
app.dependency_overrides.clear()
TRACE_ID = ""