285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""邮件 OCR 识别字段查询接口的集成测试。
|
|||
|
|
|
||
|
|
覆盖:返回附件识别原文、结构化字段、置信度、缺失字段和最近一次识别尝试,
|
||
|
|
没有识别尝试时仍能返回附件自身字段,邮件不存在、越权的处理,
|
||
|
|
以及查询动作必须留下审计记录。
|
||
|
|
|
||
|
|
统一在单事件循环内调用应用(httpx.ASGITransport),避免连接池里的连接被
|
||
|
|
跨事件循环复用。
|
||
|
|
"""
|
||
|
|
|
||
|
|
from collections.abc import AsyncIterator
|
||
|
|
from datetime import UTC, date, 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,
|
||
|
|
OffsiteFundMail,
|
||
|
|
OffsiteRecognitionAttempt,
|
||
|
|
)
|
||
|
|
|
||
|
|
RECOGNITION_PATH = "/api/v1/offsite-fund/mails/{mail_id}/recognition-fields"
|
||
|
|
|
||
|
|
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="1",
|
||
|
|
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_recognition(mail_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(RECOGNITION_PATH.format(mail_id=mail_id))
|
||
|
|
|
||
|
|
|
||
|
|
async def _seed_mail(*, with_attempt: bool = True) -> tuple[str, str]:
|
||
|
|
"""写入一封带附件、单据和识别尝试的测试邮件,返回 (mail_id, task_id)。"""
|
||
|
|
mail_id = f"M{uuid4().hex[:12]}"
|
||
|
|
attachment_id = f"{mail_id}-A01"
|
||
|
|
task_id = f"{mail_id}-A01"
|
||
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||
|
|
async with SessionFactory() as session, session.begin():
|
||
|
|
session.add(OffsiteFundMail(
|
||
|
|
mail_id=mail_id,
|
||
|
|
imap_uid=f"uid-{mail_id}",
|
||
|
|
message_id=f"<{mail_id}@integration.local>",
|
||
|
|
received_date=date(2026, 9, 10),
|
||
|
|
sender="15008108550@163.com",
|
||
|
|
return_path="15008108550@163.com",
|
||
|
|
auth_result={"spf": "pass", "dkim": "pass"},
|
||
|
|
original_eml_path="mock/offsite.eml",
|
||
|
|
status="recognized",
|
||
|
|
retry_count=0,
|
||
|
|
created_at=now,
|
||
|
|
updated_at=now,
|
||
|
|
))
|
||
|
|
session.add(OffsiteFundAttachment(
|
||
|
|
attachment_id=attachment_id,
|
||
|
|
mail_id=mail_id,
|
||
|
|
filename="申购申请单.pdf",
|
||
|
|
file_hash=uuid4().hex,
|
||
|
|
media_type="application/pdf",
|
||
|
|
size_bytes=2048,
|
||
|
|
document_type="subscription",
|
||
|
|
original_file_path="mock/申购申请单.pdf",
|
||
|
|
ocr_text="基金代码 159511 申购金额 10000",
|
||
|
|
extracted_fields={"基金代码": "159511", "基金名称": "测试基金"},
|
||
|
|
field_confidence={"基金代码": "0.95", "基金名称": "0.62"},
|
||
|
|
page_evidence={"基金代码": "layout_1_block_1"},
|
||
|
|
status="recognized",
|
||
|
|
created_at=now,
|
||
|
|
))
|
||
|
|
session.add(OffsiteFundDocument(
|
||
|
|
task_id=task_id,
|
||
|
|
mail_id=mail_id,
|
||
|
|
attachment_id=attachment_id,
|
||
|
|
document_type="subscription",
|
||
|
|
fund_code="159511",
|
||
|
|
fund_name="测试基金",
|
||
|
|
application_no=f"SUB-{uuid4().hex[:8]}",
|
||
|
|
application_date=date(2026, 9, 10),
|
||
|
|
raw_application_date="2026年09月10日",
|
||
|
|
agency="测试代销",
|
||
|
|
status="planned",
|
||
|
|
operator_decision="未处理",
|
||
|
|
created_at=now,
|
||
|
|
updated_at=now,
|
||
|
|
))
|
||
|
|
if with_attempt:
|
||
|
|
session.add(OffsiteRecognitionAttempt(
|
||
|
|
task_id=task_id,
|
||
|
|
mail_id=mail_id,
|
||
|
|
attachment_id=attachment_id,
|
||
|
|
imap_uid=f"uid-{mail_id}",
|
||
|
|
message_id=f"<{mail_id}@integration.local>",
|
||
|
|
filename="申购申请单.pdf",
|
||
|
|
file_hash=uuid4().hex,
|
||
|
|
original_file_path="mock/申购申请单.pdf",
|
||
|
|
attempt_no=1,
|
||
|
|
source="worker",
|
||
|
|
operator_id=None,
|
||
|
|
document_type="subscription",
|
||
|
|
ocr_status="success",
|
||
|
|
llm_status="success",
|
||
|
|
extracted_fields={"基金代码": "159511", "基金名称": "测试基金"},
|
||
|
|
field_confidence={"基金代码": "0.95", "基金名称": "0.62"},
|
||
|
|
missing_fields=["代销机构"],
|
||
|
|
low_confidence_fields=["基金名称"],
|
||
|
|
page_evidence={"基金代码": "layout_1_block_1"},
|
||
|
|
status="recognition_exception",
|
||
|
|
error_message=None,
|
||
|
|
started_at=now,
|
||
|
|
finished_at=now,
|
||
|
|
created_at=now,
|
||
|
|
))
|
||
|
|
return mail_id, task_id
|
||
|
|
|
||
|
|
|
||
|
|
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(mail_id: str, 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 mail_id:
|
||
|
|
await session.execute(
|
||
|
|
delete(OffsiteRecognitionAttempt).where(
|
||
|
|
OffsiteRecognitionAttempt.mail_id == mail_id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await session.execute(
|
||
|
|
delete(OffsiteFundDocument).where(OffsiteFundDocument.mail_id == mail_id)
|
||
|
|
)
|
||
|
|
await session.execute(
|
||
|
|
delete(OffsiteFundAttachment).where(
|
||
|
|
OffsiteFundAttachment.mail_id == mail_id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await session.execute(
|
||
|
|
delete(OffsiteFundMail).where(OffsiteFundMail.mail_id == mail_id)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
async def test_recognition_fields_return_attachment_and_attempt_detail() -> None:
|
||
|
|
global TRACE_ID
|
||
|
|
TRACE_ID = f"trace-recognition-{uuid4()}"
|
||
|
|
mail_id = ""
|
||
|
|
task_id = ""
|
||
|
|
try:
|
||
|
|
mail_id, task_id = await _seed_mail()
|
||
|
|
_install_context(("operator",), ("offsite:read",))
|
||
|
|
response = await _get_recognition(mail_id)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
body = response.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
data = body["data"]
|
||
|
|
assert data["mail_id"] == mail_id
|
||
|
|
assert len(data["attachments"]) == 1
|
||
|
|
attachment = data["attachments"][0]
|
||
|
|
assert attachment["document_type"] == "subscription"
|
||
|
|
assert attachment["ocr_text"] == "基金代码 159511 申购金额 10000"
|
||
|
|
assert attachment["extracted_fields"]["基金代码"] == "159511"
|
||
|
|
assert attachment["field_confidence"]["基金名称"] == "0.62"
|
||
|
|
assert attachment["missing_fields"] == ["代销机构"]
|
||
|
|
assert attachment["low_confidence_fields"] == ["基金名称"]
|
||
|
|
assert attachment["ocr_status"] == "success"
|
||
|
|
assert attachment["llm_status"] == "success"
|
||
|
|
assert attachment["latest_attempt"]["attempt_no"] == 1
|
||
|
|
assert attachment["latest_attempt"]["source"] == "worker"
|
||
|
|
assert attachment["documents"][0]["task_id"] == task_id
|
||
|
|
assert attachment["documents"][0]["fund_code"] == "159511"
|
||
|
|
assert attachment["documents"][0]["application_date"] == "2026-09-10"
|
||
|
|
assert await _count_audit("offsite.mail_recognition_viewed") == 1
|
||
|
|
finally:
|
||
|
|
await _cleanup(mail_id, task_id)
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
async def test_recognition_fields_without_attempt_keeps_attachment_fields() -> None:
|
||
|
|
"""识别尝试缺失时不能报错,仍要返回附件自身的识别字段。"""
|
||
|
|
global TRACE_ID
|
||
|
|
TRACE_ID = f"trace-recognition-{uuid4()}"
|
||
|
|
mail_id = ""
|
||
|
|
task_id = ""
|
||
|
|
try:
|
||
|
|
mail_id, task_id = await _seed_mail(with_attempt=False)
|
||
|
|
_install_context(("operator",), ("offsite:read",))
|
||
|
|
response = await _get_recognition(mail_id)
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
attachment = response.json()["data"]["attachments"][0]
|
||
|
|
assert attachment["extracted_fields"]["基金代码"] == "159511"
|
||
|
|
assert attachment["missing_fields"] == []
|
||
|
|
assert attachment["low_confidence_fields"] == []
|
||
|
|
assert attachment["ocr_status"] is None
|
||
|
|
assert attachment["latest_attempt"] is None
|
||
|
|
finally:
|
||
|
|
await _cleanup(mail_id, task_id)
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
async def test_recognition_fields_unknown_mail_returns_not_found() -> None:
|
||
|
|
global TRACE_ID
|
||
|
|
TRACE_ID = f"trace-recognition-{uuid4()}"
|
||
|
|
try:
|
||
|
|
_install_context(("operator",), ("offsite:read",))
|
||
|
|
response = await _get_recognition("M000000000000")
|
||
|
|
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json()["code"] == 404
|
||
|
|
assert response.json()["message"] == "邮件不存在"
|
||
|
|
finally:
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
async def test_recognition_fields_requires_offsite_permission() -> None:
|
||
|
|
global TRACE_ID
|
||
|
|
TRACE_ID = f"trace-recognition-{uuid4()}"
|
||
|
|
mail_id = ""
|
||
|
|
task_id = ""
|
||
|
|
try:
|
||
|
|
mail_id, task_id = await _seed_mail()
|
||
|
|
_install_context(("operator",), ())
|
||
|
|
missing_permission = await _get_recognition(mail_id)
|
||
|
|
_install_context(("customer",), ("offsite:read",))
|
||
|
|
wrong_role = await _get_recognition(mail_id)
|
||
|
|
|
||
|
|
assert missing_permission.json()["code"] == 403
|
||
|
|
assert wrong_role.json()["code"] == 403
|
||
|
|
finally:
|
||
|
|
await _cleanup(mail_id, task_id)
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TRACE_ID = ""
|