179 lines
7.3 KiB
Python
179 lines
7.3 KiB
Python
from datetime import datetime
|
|
from decimal import Decimal
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from app.core.contracts import RequestContext
|
|
from app.core.errors import InvalidStateError, ValidationAgentError
|
|
from app.core.investment_goal_contracts import (
|
|
InvestmentGoalBookPublish,
|
|
InvestmentGoalBookReview,
|
|
InvestmentGoalCreate,
|
|
)
|
|
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
|
|
from app.service.api_transaction_service import ApiTransactionService
|
|
from app.service.investment_goal_service import InvestmentGoalService
|
|
|
|
|
|
def context(*permissions: str, roles: tuple[str, ...] = ("customer",)) -> RequestContext:
|
|
return RequestContext(user_id="7", trace_id="goal-test", roles=roles, permissions=permissions)
|
|
|
|
|
|
def payload(**overrides: object) -> InvestmentGoalCreate:
|
|
values: dict[str, object] = {
|
|
"annualized_return_lower_pct": "4.0",
|
|
"annualized_return_upper_pct": "6.0",
|
|
"max_drawdown_pct": "8.0",
|
|
"liquidity_requirement": "within_30_days",
|
|
"investment_horizon_months": 36,
|
|
"benchmark_name": "中证全指收益率",
|
|
}
|
|
values.update(overrides)
|
|
return InvestmentGoalCreate.model_validate(values)
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self, goal: AdvisorInvestmentGoal | None = None) -> None:
|
|
self.goal = goal
|
|
self.content: ClientFacingContent | None = None
|
|
self.items: list[object] = []
|
|
self.executed: list[object] = []
|
|
|
|
def add(self, item: object) -> None:
|
|
self.items.append(item)
|
|
if isinstance(item, ClientFacingContent):
|
|
self.content = item
|
|
|
|
async def flush(self) -> None:
|
|
assert self.content is not None
|
|
self.content.id = 101
|
|
|
|
async def scalar(self, _statement: object) -> AdvisorInvestmentGoal | None:
|
|
return self.goal
|
|
|
|
async def get(self, _model: object, _identity: int) -> ClientFacingContent | None:
|
|
return self.content
|
|
|
|
async def execute(self, statement: object) -> None:
|
|
self.executed.append(statement)
|
|
|
|
|
|
async def run_operation(
|
|
monkeypatch: pytest.MonkeyPatch, session: FakeSession, method: str, *args: object
|
|
) -> dict[str, Any]:
|
|
async def execute(
|
|
_self: ApiTransactionService,
|
|
_context: RequestContext,
|
|
_scope: str,
|
|
_key: str | None,
|
|
_body: Any,
|
|
operation: Any,
|
|
) -> dict[str, Any]:
|
|
return cast(dict[str, Any], await operation(session))
|
|
|
|
monkeypatch.setattr(ApiTransactionService, "execute", execute)
|
|
result = await getattr(InvestmentGoalService(), method)(*args)
|
|
return cast(dict[str, Any], result)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_writes_pending_goal_and_reviewable_book(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
session = FakeSession()
|
|
result = await run_operation(
|
|
monkeypatch, session, "create", payload(), context("investment-goal:write:self"),
|
|
"goal-create-key-0001",
|
|
)
|
|
goal = next(item for item in session.items if isinstance(item, AdvisorInvestmentGoal))
|
|
assert goal.status == "pending_confirmation"
|
|
assert session.content is not None
|
|
assert session.content.review_status == "pending"
|
|
assert cast(dict[str, Any], result["data"])["goal_gap_status"] == "awaiting_confirmation"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirmation_activates_goal_but_does_not_publish_book(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
created_at = datetime(2026, 9, 10)
|
|
goal = AdvisorInvestmentGoal(
|
|
id=1, goal_no="IG-confirmation-test", customer_id=7, status="pending_confirmation",
|
|
annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"),
|
|
max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24,
|
|
benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101,
|
|
created_by=7, confirmed_by=None, confirmed_at=None,
|
|
created_at=created_at, updated_at=created_at,
|
|
)
|
|
session = FakeSession(goal)
|
|
session.content = ClientFacingContent(
|
|
id=101, customer_id=7, content_type="investment_goal_book", draft_content={},
|
|
generated_by_portal="api", review_status="pending", reviewer_user_id=None,
|
|
reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at,
|
|
)
|
|
result = await run_operation(
|
|
monkeypatch, session, "confirm", goal.goal_no,
|
|
context("investment-goal:confirm:self"), "goal-confirm-key-0001",
|
|
)
|
|
assert goal.status == "confirmed"
|
|
assert session.content.published_at is None
|
|
assert cast(dict[str, Any], result["data"])["goal_book"]["review_status"] == "pending"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_review_then_publish_requires_approved_book(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
created_at = datetime(2026, 9, 10)
|
|
goal = AdvisorInvestmentGoal(
|
|
id=1, goal_no="IG-review-test", customer_id=7, status="confirmed",
|
|
annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"),
|
|
max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24,
|
|
benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101,
|
|
created_by=7, confirmed_by=7, confirmed_at=created_at,
|
|
created_at=created_at, updated_at=created_at,
|
|
)
|
|
session = FakeSession(goal)
|
|
session.content = ClientFacingContent(
|
|
id=101, customer_id=7, content_type="investment_goal_book", draft_content={},
|
|
generated_by_portal="api", review_status="pending", reviewer_user_id=None,
|
|
reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at,
|
|
)
|
|
|
|
async def allow(*_args: object, **_kwargs: object) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr("app.service.investment_goal_service.AuthorizationService.require", allow)
|
|
with pytest.raises(InvalidStateError, match="审核通过"):
|
|
await run_operation(
|
|
monkeypatch, session, "publish_book", goal.goal_no,
|
|
InvestmentGoalBookPublish(publish=True),
|
|
context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0000",
|
|
)
|
|
await run_operation(
|
|
monkeypatch, session, "review_book", goal.goal_no,
|
|
InvestmentGoalBookReview(decision="approved"),
|
|
context("investment-goal:review", roles=("admin",)), "goal-review-key-0001",
|
|
)
|
|
assert session.content.review_status == "approved"
|
|
await run_operation(
|
|
monkeypatch, session, "publish_book", goal.goal_no,
|
|
InvestmentGoalBookPublish(publish=True),
|
|
context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0001",
|
|
)
|
|
assert session.content.review_status == "published"
|
|
assert session.content.published_at is not None
|
|
|
|
|
|
def test_goal_constraints_and_disclosures_are_validated() -> None:
|
|
with pytest.raises(ValidationError, match="lower bound"):
|
|
payload(annualized_return_lower_pct="8", annualized_return_upper_pct="6")
|
|
with pytest.raises(ValidationError):
|
|
payload(liquidity_requirement="tomorrow")
|
|
with pytest.raises(ValidationAgentError, match="收益承诺"):
|
|
InvestmentGoalService._validate_notes("希望保证收益")
|
|
book = InvestmentGoalService._goal_book("IG-1", payload())
|
|
assert "不构成收益承诺" in cast(list[str], book["disclosures"])[0]
|