Files
group_fqcd_jr/tests/integration/test_promotion_material_api.py
T

464 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import io
import shutil
from collections.abc import AsyncIterator
from pathlib import Path
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from sqlalchemy import delete, text
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.promotion_material import (
PromotionAttachment,
PromotionComplianceCheck,
PromotionDeliveryRecord,
PromotionInputSnapshot,
PromotionMaterialTask,
PromotionMaterialVersion,
PromotionReviewRecord,
)
from app.service.promotion_material_service import PromotionMaterialService
CURRENT_CONTEXT = RequestContext(
user_id="1",
trace_id="promotion-integration",
roles=("operator",),
permissions=(
"promotion:read",
"promotion:write",
"promotion:review",
"promotion:deliver",
),
data_scope="all",
)
async def override_context() -> RequestContext:
return CURRENT_CONTEXT
async def override_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
@pytest.mark.integration
def test_promotion_material_http_workflow_and_advisor_scope(tmp_path: Path) -> None:
global CURRENT_CONTEXT
trace_id = f"trace-promotion-{uuid4()}"
CURRENT_CONTEXT = RequestContext(
user_id="1",
trace_id=trace_id,
roles=("operator",),
permissions=(
"promotion:read",
"promotion:write",
"promotion:review",
"promotion:deliver",
),
data_scope="all",
)
app.dependency_overrides[build_request_context] = override_context
app.dependency_overrides[get_session] = override_session
task_no = ""
idempotency_keys: list[str] = []
try:
with TestClient(app) as client:
create_key = _key("create")
idempotency_keys.append(create_key)
created = client.post(
"/api/v1/fund-promotion-materials",
headers={"Idempotency-Key": create_key},
json={
"product_name": "南方稳健配置测试基金",
"product_code": "PROMO-001",
"material_title": "南方稳健配置测试基金产品推介材料",
"style_code": "balanced_allocation",
"output_formats": ["pptx", "poster"],
},
)
assert created.status_code == 200
assert created.json()["code"] == 0
task_no = created.json()["data"]["task_no"]
replayed = client.post(
"/api/v1/fund-promotion-materials",
headers={"Idempotency-Key": create_key},
json={
"product_name": "南方稳健配置测试基金",
"product_code": "PROMO-001",
"material_title": "南方稳健配置测试基金产品推介材料",
"style_code": "balanced_allocation",
"output_formats": ["pptx", "poster"],
},
)
assert replayed.status_code == 200
assert replayed.json()["data"] == created.json()["data"]
input_key = _key("inputs")
idempotency_keys.append(input_key)
updated = client.put(
f"/api/v1/fund-promotion-materials/{task_no}/inputs",
headers={"Idempotency-Key": input_key},
json=_inputs_payload(),
)
assert updated.status_code == 200
assert updated.json()["data"]["status"] == "input_ready"
photo_key = _key("photo")
idempotency_keys.append(photo_key)
photo = client.post(
f"/api/v1/fund-promotion-materials/{task_no}/attachments",
params={"attachment_type": "manager_photo"},
headers={"Idempotency-Key": photo_key},
files={
"file": (
"manager-photo.jpg",
_manager_photo_bytes(),
"image/jpeg",
)
},
)
assert photo.status_code == 200
assert photo.json()["data"]["attachment_type"] == "manager_photo"
performance_key = _key("performance")
idempotency_keys.append(performance_key)
performance = client.post(
f"/api/v1/fund-promotion-materials/{task_no}/attachments",
params={"attachment_type": "performance_data"},
headers={"Idempotency-Key": performance_key},
files={
"file": (
"performance.csv",
_performance_csv_bytes(),
"text/csv",
)
},
)
assert performance.status_code == 200
assert performance.json()["data"]["attachment_type"] == "performance_data"
generation_key = _key("generation")
idempotency_keys.append(generation_key)
generated = client.post(
f"/api/v1/fund-promotion-materials/{task_no}/generations",
headers={"Idempotency-Key": generation_key},
json={"output_formats": ["pptx", "poster"]},
)
assert generated.status_code == 200
generated_data = generated.json()["data"]
assert generated_data["status"] == "pending_review"
version_id = int(generated_data["material_version_id"])
assert Path(generated_data["pptx_path"]).is_file()
assert Path(generated_data["poster_path"]).is_file()
assert len(generated_data["chart_paths"]) == 1
assert Path(generated_data["chart_paths"][0]).is_file()
checks = client.get(
f"/api/v1/fund-promotion-materials/{task_no}/compliance-checks"
)
assert checks.status_code == 200
assert any(
item["rule_code"] == "overall.pass"
for item in checks.json()["data"]["findings"]
)
before_review = client.get(
f"/api/v1/fund-promotion-materials/{task_no}"
)
assert before_review.status_code == 200
assert before_review.json()["data"]["material_version"] is None
review_key = _key("review")
idempotency_keys.append(review_key)
reviewed = client.post(
f"/api/v1/fund-promotion-materials/{task_no}/reviews",
headers={"Idempotency-Key": review_key},
json={
"material_version_id": version_id,
"decision": "approved",
"comment": "集成测试审核通过",
},
)
assert reviewed.status_code == 200
assert reviewed.json()["data"]["status"] == "approved"
after_review = client.get(
f"/api/v1/fund-promotion-materials/{task_no}"
)
assert after_review.status_code == 200
assert after_review.json()["data"]["material_version"]["id"] == version_id
assert after_review.json()["data"]["material_version"]["status"] == "approved"
delivery_key = _key("delivery")
idempotency_keys.append(delivery_key)
delivered = client.post(
f"/api/v1/fund-promotion-materials/{task_no}/deliveries",
headers={"Idempotency-Key": delivery_key},
json={
"material_version_id": version_id,
"advisor_ids": [42],
"delivery_channel": "internal_record",
},
)
assert delivered.status_code == 200
assert delivered.json()["data"]["status"] == "sent"
CURRENT_CONTEXT = RequestContext(
user_id="42",
trace_id=trace_id,
roles=("advisor",),
permissions=("promotion:read",),
data_scope="self",
)
advisor_view = client.get(
f"/api/v1/fund-promotion-materials/{task_no}"
)
assert advisor_view.status_code == 200
assert advisor_view.json()["data"]["material_version"]["status"] == "sent"
CURRENT_CONTEXT = RequestContext(
user_id="43",
trace_id=trace_id,
roles=("advisor",),
permissions=("promotion:read",),
data_scope="self",
)
other_advisor_view = client.get(
f"/api/v1/fund-promotion-materials/{task_no}"
)
assert other_advisor_view.status_code == 404
# `docs/05-接口文档.md` §3.6 未定义通用 404 码,非会话资源按同一"资源隐藏"
# 语义复用 SESSION_NOT_FOUND(见 app/core/errors.py 的说明)。
assert other_advisor_view.json()["error"]["code"] == "SESSION_NOT_FOUND"
finally:
asyncio.run(_cleanup_promotion_task(task_no, trace_id, idempotency_keys))
storage_root = Path(PromotionMaterialService().storage_root)
if task_no:
shutil.rmtree(storage_root / task_no, ignore_errors=True)
app.dependency_overrides.clear()
CURRENT_CONTEXT = RequestContext(
user_id="1",
trace_id="promotion-integration",
roles=("operator",),
permissions=(
"promotion:read",
"promotion:write",
"promotion:review",
"promotion:deliver",
),
data_scope="all",
)
@pytest.mark.integration
def test_promotion_material_http_rejects_missing_key_and_permission() -> None:
global CURRENT_CONTEXT
app.dependency_overrides[build_request_context] = override_context
app.dependency_overrides[get_session] = override_session
try:
with TestClient(app) as client:
CURRENT_CONTEXT = RequestContext(
user_id="1",
trace_id=f"trace-promotion-validation-{uuid4()}",
roles=("operator",),
permissions=("promotion:write",),
data_scope="all",
)
missing_key = client.post(
"/api/v1/fund-promotion-materials",
json={
"product_name": "缺少幂等键测试",
"material_title": "缺少幂等键测试材料",
},
)
# 缺少必填幂等键属于请求校验失败:文档 §3.5/§3.6 规定 422 + AGENT_INPUT_INVALID,
# 且必须走统一错误信封。该 Header 在 Controller 上声明为可选、由 Service 校验,
# 因此走业务异常信封(field_errors 为空,原因在 message 里);
# FastAPI 原生校验错误的信封由 tests/unit/api/test_request_validation_envelope.py 覆盖。
assert missing_key.status_code == 422
assert missing_key.json()["error"]["code"] == "AGENT_INPUT_INVALID"
CURRENT_CONTEXT = RequestContext(
user_id="1",
trace_id=f"trace-promotion-forbidden-{uuid4()}",
roles=("operator",),
permissions=(),
data_scope="all",
)
forbidden = client.post(
"/api/v1/fund-promotion-materials",
headers={"Idempotency-Key": _key("forbidden")},
json={
"product_name": "无权限测试",
"material_title": "无权限测试材料",
},
)
assert forbidden.status_code == 403
# 文档 §3.6 的权威码是 AGENT_PERMISSION_DENIED(旧用例沿用未定义的 FORBIDDEN)。
assert forbidden.json()["error"]["code"] == "AGENT_PERMISSION_DENIED"
finally:
app.dependency_overrides.clear()
CURRENT_CONTEXT = RequestContext(
user_id="1",
trace_id="promotion-integration",
roles=("operator",),
permissions=(
"promotion:read",
"promotion:write",
"promotion:review",
"promotion:deliver",
),
data_scope="all",
)
def _inputs_payload() -> dict[str, object]:
return {
"product_info": {
"fund_type": "混合型",
"operation_mode": "开放式",
"product_status": "new_product",
"investment_objective": "通过股债灵活配置实现长期资产配置目标",
"benchmark": "中证全债指数收益率×60%+沪深300指数收益率×40%",
"risk_level": "R3",
},
"manager_info": {
"manager_name": "张三",
"management_company": "南方基金管理有限公司",
"registration_code": "P10000001",
"employment_years": "10年",
"investment_management_experience": "8年公募基金投资管理经验",
"profile": "负责资产配置和组合管理。",
},
"team_info": {
"team_description": "具备完整的投资、研究和风险管理分工。",
"research_capability": "覆盖宏观、行业和信用研究。",
},
"strategy_info": {
"investment_scope": "股票、债券、货币市场工具及法律法规允许的其他资产。",
"strategy": "通过大类资产配置和基本面研究动态调整组合。",
"restrictions": "遵守法律法规、基金合同及监管限制。",
"index_tool_attribute": "用于长期资产配置和组合风险管理。",
},
"fee_structure": {
"subscription_fee": "1.0%",
"purchase_fee": "1.0%",
"redemption_fee": "0.5%",
"sales_service_fee": "0.2%",
"management_fee": "1.0%",
"custody_fee": "0.2%",
"client_maintenance_fee": "不适用",
},
"performance_info": {
"as_of_date": "2026-08-31",
"history_months": 12,
"product_return": "8.6%",
"max_drawdown": "-5.2%",
"volatility": "10.1%",
"sharpe_ratio": "0.82",
"show_product_performance": True,
"show_manager_performance": True,
"ranking": {
"enabled": False,
"institution_name": None,
"evaluation_period_years": None,
"ranking_text": None,
"public_source": None,
},
},
"risk_disclosure": {
"special_risks": ["市场风险", "流动性风险"],
"additional_notes": "本材料仅供专业投顾内部研究使用。",
},
"source_notes": {"source": "promotion-integration-test"},
}
def _manager_photo_bytes() -> bytes:
image = Image.new("RGB", (640, 800), (24, 65, 110))
output = io.BytesIO()
image.save(output, format="JPEG", quality=90)
return output.getvalue()
def _performance_csv_bytes() -> bytes:
return (
"日期,产品收益率,业绩比较基准收益率,基金经理代表产品收益率\n"
"2025-01-31,1.0%,0.6%,0.8%\n"
"2025-02-28,2.5%,1.4%,2.0%\n"
"2025-03-31,3.2%,2.0%,2.7%\n"
"2025-04-30,4.1%,2.8%,3.5%\n"
"2025-05-31,5.0%,3.6%,4.2%\n"
"2025-06-30,5.8%,4.2%,5.1%\n"
"2025-07-31,6.4%,4.9%,5.9%\n"
"2025-08-31,7.1%,5.5%,6.6%\n"
).encode("utf-8-sig")
def _key(prefix: str) -> str:
return f"promotion-it-{prefix}-{uuid4().hex}"
async def _cleanup_promotion_task(
task_no: str, trace_id: str, idempotency_keys: list[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_no:
await session.execute(
delete(PromotionComplianceCheck).where(
PromotionComplianceCheck.task_no == task_no
)
)
await session.execute(
delete(PromotionReviewRecord).where(
PromotionReviewRecord.task_no == task_no
)
)
await session.execute(
delete(PromotionDeliveryRecord).where(
PromotionDeliveryRecord.task_no == task_no
)
)
await session.execute(
delete(PromotionMaterialVersion).where(
PromotionMaterialVersion.task_no == task_no
)
)
await session.execute(
delete(PromotionAttachment).where(PromotionAttachment.task_no == task_no)
)
await session.execute(
delete(PromotionInputSnapshot).where(
PromotionInputSnapshot.task_no == task_no
)
)
await session.execute(
delete(PromotionMaterialTask).where(PromotionMaterialTask.task_no == task_no)
)
if idempotency_keys:
await session.execute(
text(
"DELETE FROM api_request_receipt "
"WHERE idempotency_key IN :keys"
).bindparams(keys=tuple(idempotency_keys))
)