Files
group_fqcd_jr/tests/unit/service/test_investment_goal_service.py
T

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]