425 lines
17 KiB
Python
425 lines
17 KiB
Python
import asyncio
|
|||
|
|
from collections.abc import AsyncIterator
|
||
|
|
from pathlib import Path
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from sqlalchemy import delete, func, 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
|
||
|
|
from app.main import app
|
||
|
|
from app.model.audit import InteractionAudit
|
||
|
|
from app.model.offsite_fund import (
|
||
|
|
OffsiteExecutionPlanTask,
|
||
|
|
OffsiteFundAttachment,
|
||
|
|
OffsiteFundDocument,
|
||
|
|
OffsiteFundMail,
|
||
|
|
OffsiteNotification,
|
||
|
|
OffsiteQueryRecord,
|
||
|
|
OffsiteRuleResult,
|
||
|
|
)
|
||
|
|
from app.service.offsite_smtp_adapter import SmtpSendResult
|
||
|
|
|
||
|
|
TEST_TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
async def override_context() -> RequestContext:
|
||
|
|
return RequestContext(
|
||
|
|
user_id="1",
|
||
|
|
trace_id=TEST_TRACE_ID or str(uuid4()),
|
||
|
|
roles=("operator",),
|
||
|
|
permissions=("offsite:write",),
|
||
|
|
data_scope="all",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def override_session() -> AsyncIterator[AsyncSession]:
|
||
|
|
async with SessionFactory() as session:
|
||
|
|
yield session
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
def test_offsite_recognized_mail_persists_workflow_and_notification() -> None:
|
||
|
|
global TEST_TRACE_ID
|
||
|
|
TEST_TRACE_ID = f"trace-offsite-{uuid4()}"
|
||
|
|
uid = f"offsite-{uuid4()}"
|
||
|
|
message_id = f"<{uuid4()}@integration.local>"
|
||
|
|
payload = {
|
||
|
|
"imap_uid": uid,
|
||
|
|
"message_id": message_id,
|
||
|
|
"sender": "15008108550@163.com",
|
||
|
|
"return_path": "15008108550@163.com",
|
||
|
|
"auth_result": {"spf": "pass", "dkim": "pass"},
|
||
|
|
"eml_path": "mock/offsite.eml",
|
||
|
|
"attachments": [
|
||
|
|
{
|
||
|
|
"filename": "申购申请单.pdf",
|
||
|
|
"file_hash": f"hash-{uuid4().hex}",
|
||
|
|
"media_type": "application/pdf",
|
||
|
|
"size_bytes": 2048,
|
||
|
|
"document_type": "subscription",
|
||
|
|
"ocr_text": "基金代码 000001 申购金额 10000",
|
||
|
|
"extracted_fields": {
|
||
|
|
"基金代码": "000001",
|
||
|
|
"基金名称": "测试基金",
|
||
|
|
"账户标识": "ACCT-001",
|
||
|
|
"投资者名称": "测试客户",
|
||
|
|
"申请编号": f"SUB-{uuid4().hex[:8]}",
|
||
|
|
"申请日期": "2026-09-10",
|
||
|
|
"代销机构": "测试代销",
|
||
|
|
"申购金额": "10000",
|
||
|
|
"金额单位": "元",
|
||
|
|
"最新净值": "1.0000",
|
||
|
|
"基金最新总份额": "1000000",
|
||
|
|
"申请前持有份额": "1000",
|
||
|
|
},
|
||
|
|
"field_confidence": {"基金代码": "0.99", "申购金额": "0.98"},
|
||
|
|
"page_evidence": {"基金代码": [1], "申购金额": [1]},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
app.dependency_overrides[build_request_context] = override_context
|
||
|
|
app.dependency_overrides[get_session] = override_session
|
||
|
|
mail_id = ""
|
||
|
|
task_id = ""
|
||
|
|
try:
|
||
|
|
with TestClient(app) as client:
|
||
|
|
response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload)
|
||
|
|
assert response.status_code == 200
|
||
|
|
body = response.json()
|
||
|
|
assert body["code"] == 0
|
||
|
|
mail_id = body["data"]["mail_id"]
|
||
|
|
task_id = body["data"]["documents"][0]["task_id"]
|
||
|
|
assert body["data"]["documents"][0]["rule_results"] == {
|
||
|
|
"subscription_minimum_amount": "正常",
|
||
|
|
"subscription_holding_ratio": "正常",
|
||
|
|
"subscription_single_share_limit": "正常",
|
||
|
|
}
|
||
|
|
|
||
|
|
duplicate = client.post("/api/v1/offsite-fund/recognized-mails", json=payload)
|
||
|
|
assert duplicate.status_code == 200
|
||
|
|
assert duplicate.json()["data"] == {"mail_id": mail_id}
|
||
|
|
|
||
|
|
confirm = client.post(
|
||
|
|
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||
|
|
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||
|
|
)
|
||
|
|
assert confirm.status_code == 200
|
||
|
|
assert confirm.json()["code"] == 0
|
||
|
|
|
||
|
|
recalc = client.post(
|
||
|
|
"/api/v1/offsite-fund/settlement-statistics/recalculate",
|
||
|
|
json={"fund_code": "000001", "application_date": "2026-09-10"},
|
||
|
|
)
|
||
|
|
assert recalc.status_code == 200
|
||
|
|
assert recalc.json()["data"]["subscription_amount_yuan"] == "10000.0000"
|
||
|
|
|
||
|
|
notice = client.post(
|
||
|
|
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||
|
|
json={"notification_type": "settlement", "operator_id": "operator-001"},
|
||
|
|
)
|
||
|
|
assert notice.status_code == 200
|
||
|
|
assert notice.json()["data"]["notification_id"]
|
||
|
|
|
||
|
|
asyncio.run(_assert_offsite_rows(mail_id, task_id, TEST_TRACE_ID))
|
||
|
|
finally:
|
||
|
|
asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID))
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TEST_TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
def test_offsite_trigger_nl2sql_uses_query_dict_adapter() -> None:
|
||
|
|
global TEST_TRACE_ID
|
||
|
|
TEST_TRACE_ID = f"trace-offsite-nl2sql-{uuid4()}"
|
||
|
|
uid = f"offsite-{uuid4()}"
|
||
|
|
message_id = f"<{uuid4()}@integration.local>"
|
||
|
|
payload = _subscription_payload(uid, message_id)
|
||
|
|
app.dependency_overrides[build_request_context] = override_context
|
||
|
|
app.dependency_overrides[get_session] = override_session
|
||
|
|
mail_id = ""
|
||
|
|
task_id = ""
|
||
|
|
try:
|
||
|
|
with TestClient(app) as client:
|
||
|
|
created = client.post("/api/v1/offsite-fund/recognized-mails", json=payload)
|
||
|
|
assert created.status_code == 200
|
||
|
|
body = created.json()
|
||
|
|
mail_id = body["data"]["mail_id"]
|
||
|
|
task_id = body["data"]["documents"][0]["task_id"]
|
||
|
|
|
||
|
|
response = client.post(
|
||
|
|
f"/api/tasks/{task_id}/trigger-agent-nl2sql",
|
||
|
|
json={"operator_id": "1", "manual_confirmed": True},
|
||
|
|
)
|
||
|
|
assert response.status_code == 200
|
||
|
|
data = response.json()["data"]
|
||
|
|
assert data["task_id"] == task_id
|
||
|
|
assert len(data["queries"]) == 2
|
||
|
|
assert {item["status"] for item in data["queries"]} <= {
|
||
|
|
"ready", "success", "need_confirmation", "rejected", "error",
|
||
|
|
}
|
||
|
|
|
||
|
|
asyncio.run(_assert_nl2sql_rows(task_id))
|
||
|
|
finally:
|
||
|
|
asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID))
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TEST_TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
def test_offsite_low_confidence_document_goes_to_recognition_exception() -> None:
|
||
|
|
global TEST_TRACE_ID
|
||
|
|
TEST_TRACE_ID = f"trace-offsite-low-confidence-{uuid4()}"
|
||
|
|
uid = f"offsite-{uuid4()}"
|
||
|
|
message_id = f"<{uuid4()}@integration.local>"
|
||
|
|
payload = _subscription_payload(uid, message_id)
|
||
|
|
payload["attachments"][0]["field_confidence"] = {"基金代码": "0.79", "申购金额": "0.99"}
|
||
|
|
app.dependency_overrides[build_request_context] = override_context
|
||
|
|
app.dependency_overrides[get_session] = override_session
|
||
|
|
mail_id = ""
|
||
|
|
task_id = ""
|
||
|
|
try:
|
||
|
|
with TestClient(app) as client:
|
||
|
|
response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload)
|
||
|
|
assert response.status_code == 200
|
||
|
|
body = response.json()
|
||
|
|
mail_id = body["data"]["mail_id"]
|
||
|
|
document = body["data"]["documents"][0]
|
||
|
|
task_id = document["task_id"]
|
||
|
|
assert document["status"] == "recognition_exception"
|
||
|
|
finally:
|
||
|
|
asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID))
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TEST_TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
def test_offsite_service_rejects_missing_permission() -> None:
|
||
|
|
async def no_permission_context() -> RequestContext:
|
||
|
|
return RequestContext(
|
||
|
|
user_id="1",
|
||
|
|
trace_id=f"trace-offsite-denied-{uuid4()}",
|
||
|
|
roles=("operator",),
|
||
|
|
permissions=(),
|
||
|
|
)
|
||
|
|
|
||
|
|
payload = _subscription_payload(f"offsite-{uuid4()}", f"<{uuid4()}@integration.local>")
|
||
|
|
app.dependency_overrides[build_request_context] = no_permission_context
|
||
|
|
app.dependency_overrides[get_session] = override_session
|
||
|
|
try:
|
||
|
|
with TestClient(app) as client:
|
||
|
|
response = client.post("/api/v1/offsite-fund/recognized-mails", json=payload)
|
||
|
|
assert response.status_code == 200
|
||
|
|
assert response.json()["code"] == 403
|
||
|
|
assert "缺少场外基金操作权限" in response.json()["message"]
|
||
|
|
finally:
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.integration
|
||
|
|
def test_offsite_mail_return_send_updates_notification_without_external_call(
|
||
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
global TEST_TRACE_ID
|
||
|
|
TEST_TRACE_ID = f"trace-offsite-send-{uuid4()}"
|
||
|
|
uid = f"offsite-{uuid4()}"
|
||
|
|
message_id = f"<{uuid4()}@integration.local>"
|
||
|
|
payload = _subscription_payload(uid, message_id)
|
||
|
|
attachment_path = tmp_path / "申购申请单.pdf"
|
||
|
|
attachment_path.write_bytes(b"pdf-bytes")
|
||
|
|
payload["attachments"][0]["original_file_path"] = str(attachment_path)
|
||
|
|
|
||
|
|
class FakeDryRunSender:
|
||
|
|
def __init__(self, _settings: object) -> None:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def send_reply(self, request: object) -> SmtpSendResult:
|
||
|
|
del request
|
||
|
|
return SmtpSendResult(
|
||
|
|
status="待发送",
|
||
|
|
dry_run=True,
|
||
|
|
provider_message_id=None,
|
||
|
|
failure_reason=None,
|
||
|
|
retry_count=0,
|
||
|
|
request_summary={"provider": "test"},
|
||
|
|
)
|
||
|
|
|
||
|
|
monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeDryRunSender)
|
||
|
|
app.dependency_overrides[build_request_context] = override_context
|
||
|
|
app.dependency_overrides[get_session] = override_session
|
||
|
|
mail_id = ""
|
||
|
|
task_id = ""
|
||
|
|
notification_id = 0
|
||
|
|
try:
|
||
|
|
with TestClient(app) as client:
|
||
|
|
created = client.post("/api/v1/offsite-fund/recognized-mails", json=payload)
|
||
|
|
assert created.status_code == 200
|
||
|
|
data = created.json()["data"]
|
||
|
|
mail_id = data["mail_id"]
|
||
|
|
task_id = data["documents"][0]["task_id"]
|
||
|
|
|
||
|
|
confirmed = client.post(
|
||
|
|
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
||
|
|
json={"decision": "确认正常", "operator_id": "operator-001"},
|
||
|
|
)
|
||
|
|
assert confirmed.status_code == 200
|
||
|
|
|
||
|
|
notice = client.post(
|
||
|
|
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
||
|
|
json={"notification_type": "mail_return", "operator_id": "operator-001"},
|
||
|
|
)
|
||
|
|
assert notice.status_code == 200
|
||
|
|
notification_id = int(notice.json()["data"]["notification_id"])
|
||
|
|
|
||
|
|
sent = client.post(
|
||
|
|
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
||
|
|
json={
|
||
|
|
"operator_id": "operator-001",
|
||
|
|
"operator_confirmed": True,
|
||
|
|
"final_content": "运营确认后的回复正文",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
assert sent.status_code == 200
|
||
|
|
assert sent.json()["data"] == {
|
||
|
|
"notification_id": str(notification_id),
|
||
|
|
"status": "待发送",
|
||
|
|
"dry_run": True,
|
||
|
|
"provider_message_id": None,
|
||
|
|
"failure_reason": None,
|
||
|
|
"retry_count": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
asyncio.run(_assert_notification_pending(notification_id))
|
||
|
|
finally:
|
||
|
|
asyncio.run(_cleanup_offsite_rows(mail_id, task_id, TEST_TRACE_ID))
|
||
|
|
app.dependency_overrides.clear()
|
||
|
|
TEST_TRACE_ID = ""
|
||
|
|
|
||
|
|
|
||
|
|
def _subscription_payload(uid: str, message_id: str) -> dict[str, object]:
|
||
|
|
return {
|
||
|
|
"imap_uid": uid,
|
||
|
|
"message_id": message_id,
|
||
|
|
"sender": "15008108550@163.com",
|
||
|
|
"return_path": "15008108550@163.com",
|
||
|
|
"auth_result": {"spf": "pass", "dkim": "pass"},
|
||
|
|
"eml_path": "mock/offsite.eml",
|
||
|
|
"attachments": [
|
||
|
|
{
|
||
|
|
"filename": "申购申请单.pdf",
|
||
|
|
"file_hash": f"hash-{uuid4().hex}",
|
||
|
|
"original_file_path": "mock/申购申请单.pdf",
|
||
|
|
"media_type": "application/pdf",
|
||
|
|
"size_bytes": 2048,
|
||
|
|
"document_type": "subscription",
|
||
|
|
"ocr_text": "基金代码 000001 申购金额 10000",
|
||
|
|
"extracted_fields": {
|
||
|
|
"基金代码": "000001",
|
||
|
|
"基金名称": "测试基金",
|
||
|
|
"账户标识": "ACCT-001",
|
||
|
|
"投资者名称": "测试客户",
|
||
|
|
"申请编号": f"SUB-{uuid4().hex[:8]}",
|
||
|
|
"申请日期": "2026-09-10",
|
||
|
|
"代销机构": "测试代销",
|
||
|
|
"申购金额": "10000",
|
||
|
|
"金额单位": "元",
|
||
|
|
"最新净值": "1.0000",
|
||
|
|
"基金最新总份额": "1000000",
|
||
|
|
"申请前持有份额": "1000",
|
||
|
|
},
|
||
|
|
"field_confidence": {"基金代码": "0.99", "申购金额": "0.98"},
|
||
|
|
"page_evidence": {"基金代码": [1], "申购金额": [1]},
|
||
|
|
}
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def _assert_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> None:
|
||
|
|
async with SessionFactory() as session:
|
||
|
|
assert await _count(session, OffsiteFundMail, OffsiteFundMail.mail_id == mail_id) == 1
|
||
|
|
assert await _count(
|
||
|
|
session, OffsiteFundAttachment, OffsiteFundAttachment.mail_id == mail_id
|
||
|
|
) == 1
|
||
|
|
assert await _count(
|
||
|
|
session, OffsiteFundDocument, OffsiteFundDocument.task_id == task_id
|
||
|
|
) == 1
|
||
|
|
assert await _count(session, OffsiteRuleResult, OffsiteRuleResult.task_id == task_id) == 3
|
||
|
|
assert await _count(
|
||
|
|
session, OffsiteExecutionPlanTask, OffsiteExecutionPlanTask.task_id == task_id
|
||
|
|
) == 9
|
||
|
|
assert await _count(
|
||
|
|
session, OffsiteNotification, OffsiteNotification.business_key == task_id
|
||
|
|
) == 1
|
||
|
|
assert await _count(
|
||
|
|
session, InteractionAudit, InteractionAudit.action_type.like("offsite.%")
|
||
|
|
) >= 1
|
||
|
|
|
||
|
|
|
||
|
|
async def _assert_nl2sql_rows(task_id: str) -> None:
|
||
|
|
async with SessionFactory() as session:
|
||
|
|
assert await _count(session, OffsiteQueryRecord, OffsiteQueryRecord.task_id == task_id) == 2
|
||
|
|
tasks = (await session.execute(select(OffsiteExecutionPlanTask).where(
|
||
|
|
OffsiteExecutionPlanTask.task_id == task_id,
|
||
|
|
OffsiteExecutionPlanTask.stage == "查询",
|
||
|
|
OffsiteExecutionPlanTask.rule_code != "subscription_minimum_amount",
|
||
|
|
))).scalars().all()
|
||
|
|
assert {task.status for task in tasks} <= {"已完成", "查询失败", "无法判断"}
|
||
|
|
assert all(task.output_json is not None for task in tasks)
|
||
|
|
|
||
|
|
|
||
|
|
async def _assert_notification_pending(notification_id: int) -> None:
|
||
|
|
async with SessionFactory() as session:
|
||
|
|
notice = await session.scalar(select(OffsiteNotification).where(
|
||
|
|
OffsiteNotification.id == notification_id
|
||
|
|
))
|
||
|
|
assert notice is not None
|
||
|
|
assert notice.status == "待发送"
|
||
|
|
assert notice.provider_message_id is None
|
||
|
|
assert notice.failure_reason is None
|
||
|
|
assert notice.sent_at is None
|
||
|
|
|
||
|
|
|
||
|
|
async def _count(session: AsyncSession, model: type, criterion: object) -> int:
|
||
|
|
return int(await session.scalar(select(func.count()).select_from(model).where(criterion)) or 0)
|
||
|
|
|
||
|
|
|
||
|
|
async def _cleanup_offsite_rows(mail_id: str, task_id: str, trace_id: str) -> None:
|
||
|
|
if not mail_id and not task_id:
|
||
|
|
return
|
||
|
|
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(OffsiteNotification).where(OffsiteNotification.business_key == 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(OffsiteExecutionPlanTask).where(
|
||
|
|
OffsiteExecutionPlanTask.task_id == task_id
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await session.execute(
|
||
|
|
delete(OffsiteFundDocument).where(OffsiteFundDocument.task_id == task_id)
|
||
|
|
)
|
||
|
|
if mail_id:
|
||
|
|
await session.execute(
|
||
|
|
delete(OffsiteFundAttachment).where(OffsiteFundAttachment.mail_id == mail_id)
|
||
|
|
)
|
||
|
|
await session.execute(delete(OffsiteFundMail).where(OffsiteFundMail.mail_id == mail_id))
|