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

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"