190 lines
6.1 KiB
Python
190 lines
6.1 KiB
Python
from datetime import UTC, datetime
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
|
|
from app.core.contracts import RequestContext
|
|
from app.core.errors import ConflictAgentError
|
|
from app.core.recommendation_plan_contracts import (
|
|
RecommendationPlanGenerate,
|
|
RecommendationPlanReview,
|
|
)
|
|
from app.model.advisor import ClientFacingContent
|
|
from app.model.audit import InteractionAudit
|
|
from app.repository.recommendation_plan_repository import RecommendationPlanRepository
|
|
from app.service.api_transaction_service import ApiTransactionService
|
|
from app.service.product_recommendation_service import ProductRecommendationService
|
|
from app.service.recommendation_plan_service import RecommendationPlanService
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self) -> None:
|
|
self.items: list[object] = []
|
|
|
|
def add(self, item: object) -> None:
|
|
self.items.append(item)
|
|
|
|
async def flush(self) -> None:
|
|
for item in self.items:
|
|
if isinstance(item, ClientFacingContent) and item.id is None:
|
|
item.id = 101
|
|
|
|
|
|
def customer_context() -> RequestContext:
|
|
return RequestContext(
|
|
user_id="7",
|
|
trace_id="recommendation-plan-test",
|
|
roles=("customer",),
|
|
permissions=("recommendation-plan:generate:self",),
|
|
)
|
|
|
|
|
|
def reviewer_context() -> RequestContext:
|
|
return RequestContext(
|
|
user_id="9",
|
|
trace_id="recommendation-plan-review-test",
|
|
roles=("admin",),
|
|
permissions=("recommendation-plan:review",),
|
|
)
|
|
|
|
|
|
def pending_plan() -> ClientFacingContent:
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|
return ClientFacingContent(
|
|
id=101,
|
|
customer_id=7,
|
|
content_type="advisor_recommendation_plan",
|
|
draft_content={"products": [{"product_code": "159001"}]},
|
|
generated_by_portal="api",
|
|
review_status="pending_review",
|
|
reviewer_user_id=None,
|
|
reviewed_at=None,
|
|
published_at=None,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
|
|
async def _transaction(
|
|
_self: ApiTransactionService,
|
|
_context: RequestContext,
|
|
_scope: str,
|
|
_key: str | None,
|
|
_body: Any,
|
|
operation: Any,
|
|
) -> dict[str, Any]:
|
|
return cast(dict[str, Any], await operation(FakeSession()))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_persists_suitability_filtered_draft_for_review(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def recommend(
|
|
_self: ProductRecommendationService, _payload: object, _context: RequestContext
|
|
) -> dict[str, object]:
|
|
return {
|
|
"status": "ready",
|
|
"products": [{"product_code": "159001", "risk_level": "R3"}],
|
|
"graph_degraded": False,
|
|
"analysis_only": True,
|
|
}
|
|
|
|
monkeypatch.setattr(ApiTransactionService, "execute", _transaction)
|
|
monkeypatch.setattr(ProductRecommendationService, "recommend_for_agent", recommend)
|
|
|
|
result = await RecommendationPlanService().generate(
|
|
RecommendationPlanGenerate(limit=1), customer_context(), "recommendation-plan-0001"
|
|
)
|
|
|
|
data = cast(dict[str, Any], result["data"])
|
|
assert data["review_status"] == "pending_review"
|
|
assert data["published_at"] is None
|
|
content = cast(dict[str, Any], data["content"])
|
|
assert content["products"] == [{"product_code": "159001", "risk_level": "R3"}]
|
|
assert content["excluded_candidates"] == []
|
|
assert content["selection_summary"] == {}
|
|
assert content["analysis_only"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approval_publishes_once_and_records_review_comment(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
plan = pending_plan()
|
|
|
|
async def plan_lookup(
|
|
_self: RecommendationPlanRepository, content_id: int, *, lock: bool = False
|
|
) -> ClientFacingContent | None:
|
|
assert content_id == 101
|
|
assert lock is True
|
|
return plan
|
|
|
|
monkeypatch.setattr(ApiTransactionService, "execute", _transaction)
|
|
monkeypatch.setattr(RecommendationPlanRepository, "plan", plan_lookup)
|
|
|
|
result = await RecommendationPlanService().review(
|
|
101,
|
|
RecommendationPlanReview(decision="approved", comment="evidence reviewed"),
|
|
reviewer_context(),
|
|
"recommendation-review-0001",
|
|
)
|
|
|
|
data = cast(dict[str, Any], result["data"])
|
|
assert data["review_status"] == "approved"
|
|
assert data["published_at"] is not None
|
|
assert plan.reviewer_user_id == 9
|
|
assert plan.draft_content["review"] == {
|
|
"decision": "approved",
|
|
"comment": "evidence reviewed",
|
|
"reviewed_at": data["reviewed_at"],
|
|
}
|
|
|
|
with pytest.raises(ConflictAgentError, match="RECOMMENDATION_PLAN_NOT_PENDING_REVIEW"):
|
|
await RecommendationPlanService().review(
|
|
101,
|
|
RecommendationPlanReview(decision="approved"),
|
|
reviewer_context(),
|
|
"recommendation-review-0002",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejection_is_not_published_and_is_audited(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
plan = pending_plan()
|
|
session = FakeSession()
|
|
|
|
async def transaction(
|
|
_self: ApiTransactionService,
|
|
_context: RequestContext,
|
|
_scope: str,
|
|
_key: str | None,
|
|
_body: Any,
|
|
operation: Any,
|
|
) -> dict[str, Any]:
|
|
return cast(dict[str, Any], await operation(session))
|
|
|
|
async def plan_lookup(
|
|
_self: RecommendationPlanRepository, _content_id: int, *, lock: bool = False
|
|
) -> ClientFacingContent | None:
|
|
assert lock is True
|
|
return plan
|
|
|
|
monkeypatch.setattr(ApiTransactionService, "execute", transaction)
|
|
monkeypatch.setattr(RecommendationPlanRepository, "plan", plan_lookup)
|
|
|
|
result = await RecommendationPlanService().review(
|
|
101,
|
|
RecommendationPlanReview(decision="rejected", comment="contract evidence expired"),
|
|
reviewer_context(),
|
|
"recommendation-review-0003",
|
|
)
|
|
|
|
data = cast(dict[str, Any], result["data"])
|
|
assert data["review_status"] == "rejected"
|
|
assert data["published_at"] is None
|
|
audit = next(item for item in session.items if isinstance(item, InteractionAudit))
|
|
assert audit.action_type == "advisor.recommendation_plan_rejected"
|