276 lines
10 KiB
Python
276 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
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, 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
|
|
|
|
TRACE_ID = ""
|
|
|
|
|
|
async def override_context() -> RequestContext:
|
|
return RequestContext(
|
|
user_id="1",
|
|
trace_id=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_successful_notification_send_is_idempotent(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
global TRACE_ID
|
|
TRACE_ID = f"trace-send-success-{uuid4()}"
|
|
payload = _subscription_payload()
|
|
attachment_path = tmp_path / "申购申请单.pdf"
|
|
attachment_path.write_bytes(b"original-pdf")
|
|
payload["attachments"][0]["original_file_path"] = str(attachment_path)
|
|
sent_requests: list[object] = []
|
|
|
|
class FakeSuccessSender:
|
|
def __init__(self, _settings: object) -> None:
|
|
pass
|
|
|
|
def send_reply(self, request: object) -> SmtpSendResult:
|
|
sent_requests.append(request)
|
|
return SmtpSendResult(
|
|
status="发送成功",
|
|
dry_run=False,
|
|
provider_message_id="provider-test-001",
|
|
failure_reason=None,
|
|
retry_count=0,
|
|
request_summary={"provider": "test"},
|
|
)
|
|
|
|
monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeSuccessSender)
|
|
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)
|
|
data = created.json()["data"]
|
|
mail_id = data["mail_id"]
|
|
task_id = data["documents"][0]["task_id"]
|
|
_confirm_and_create_notice(client, task_id)
|
|
notice = client.post(
|
|
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
|
json={"notification_type": "mail_return", "operator_id": "operator-001"},
|
|
)
|
|
notification_id = int(notice.json()["data"]["notification_id"])
|
|
|
|
first = client.post(
|
|
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
|
json={"operator_id": "operator-001", "operator_confirmed": True},
|
|
)
|
|
second = client.post(
|
|
f"/api/v1/offsite-fund/notifications/{notification_id}/send",
|
|
json={"operator_id": "operator-001", "operator_confirmed": True},
|
|
)
|
|
|
|
assert first.json()["data"]["status"] == "发送成功"
|
|
assert first.json()["data"]["provider_message_id"] == "provider-test-001"
|
|
assert second.json()["data"]["status"] == "发送成功"
|
|
assert len(sent_requests) == 1
|
|
asyncio.run(_assert_notification(notification_id, "发送成功", "provider-test-001"))
|
|
asyncio.run(_assert_mail_status(mail_id, "normal_return_sent"))
|
|
finally:
|
|
asyncio.run(_cleanup(mail_id, task_id, notification_id, TRACE_ID))
|
|
app.dependency_overrides.clear()
|
|
TRACE_ID = ""
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_failed_notification_send_persists_failure_and_retry_count(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
global TRACE_ID
|
|
TRACE_ID = f"trace-send-failure-{uuid4()}"
|
|
payload = _subscription_payload()
|
|
attachment_path = tmp_path / "申购申请单.pdf"
|
|
attachment_path.write_bytes(b"original-pdf")
|
|
payload["attachments"][0]["original_file_path"] = str(attachment_path)
|
|
|
|
class FakeFailureSender:
|
|
def __init__(self, _settings: object) -> None:
|
|
pass
|
|
|
|
def send_reply(self, request: object) -> SmtpSendResult:
|
|
del request
|
|
return SmtpSendResult(
|
|
status="发送失败",
|
|
dry_run=False,
|
|
provider_message_id=None,
|
|
failure_reason="SMTPException",
|
|
retry_count=1,
|
|
request_summary={"provider": "test"},
|
|
)
|
|
|
|
monkeypatch.setattr("app.service.offsite_fund_service.OffsiteSmtpSender", FakeFailureSender)
|
|
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)
|
|
data = created.json()["data"]
|
|
mail_id = data["mail_id"]
|
|
task_id = data["documents"][0]["task_id"]
|
|
_confirm_and_create_notice(client, task_id)
|
|
notice = client.post(
|
|
f"/api/v1/offsite-fund/documents/{task_id}/notifications",
|
|
json={"notification_type": "mail_return", "operator_id": "operator-001"},
|
|
)
|
|
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},
|
|
)
|
|
|
|
assert sent.json()["data"]["status"] == "发送失败"
|
|
assert sent.json()["data"]["failure_reason"] == "SMTPException"
|
|
assert sent.json()["data"]["retry_count"] == 1
|
|
asyncio.run(_assert_notification(notification_id, "发送失败", None))
|
|
finally:
|
|
asyncio.run(_cleanup(mail_id, task_id, notification_id, TRACE_ID))
|
|
app.dependency_overrides.clear()
|
|
TRACE_ID = ""
|
|
|
|
|
|
def _confirm_and_create_notice(client: TestClient, task_id: str) -> None:
|
|
confirmed = client.post(
|
|
f"/api/v1/offsite-fund/documents/{task_id}/confirmations",
|
|
json={"decision": "确认正常", "operator_id": "operator-001"},
|
|
)
|
|
assert confirmed.status_code == 200
|
|
|
|
|
|
def _subscription_payload() -> dict[str, object]:
|
|
suffix = uuid4().hex[:8]
|
|
return {
|
|
"imap_uid": f"offsite-{suffix}",
|
|
"message_id": f"<{suffix}@integration.local>",
|
|
"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-{suffix}",
|
|
"申请日期": "2026-09-10",
|
|
"代销机构": "测试代销",
|
|
"申购金额": "10000",
|
|
"金额单位": "元",
|
|
"最新净值": "1.0000",
|
|
"基金最新总份额": "1000000",
|
|
"申请前持有份额": "1000",
|
|
},
|
|
"field_confidence": {"基金代码": "0.99", "申购金额": "0.98"},
|
|
"page_evidence": {"基金代码": [1], "申购金额": [1]},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
async def _assert_notification(
|
|
notification_id: int, status: str, provider_message_id: str | None
|
|
) -> 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 == status
|
|
assert notice.provider_message_id == provider_message_id
|
|
if status == "发送成功":
|
|
assert notice.sent_at is not None
|
|
else:
|
|
assert notice.sent_at is None
|
|
|
|
|
|
async def _assert_mail_status(mail_id: str, status: str) -> None:
|
|
async with SessionFactory() as session:
|
|
mail = await session.scalar(select(OffsiteFundMail).where(
|
|
OffsiteFundMail.mail_id == mail_id
|
|
))
|
|
assert mail is not None
|
|
assert mail.status == status
|
|
|
|
|
|
async def _cleanup(mail_id: str, task_id: str, notification_id: int, trace_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 notification_id:
|
|
await session.execute(delete(OffsiteNotification).where(
|
|
OffsiteNotification.id == notification_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(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
|
|
))
|