- Added new modules for advisor compliance, KYC sessions, and script templates, enhancing the advisor agent's capabilities. - Implemented a comprehensive API structure under the `/api/advisor-agent` prefix, ensuring clear organization and access to new features. - Established database models and repositories for compliance rules and KYC sessions, facilitating robust data management. - Integrated exception handling and response models to improve error management and user feedback. - Updated settings to include new configurations for compliance and KYC features, ensuring flexibility and adaptability. This update significantly expands the advisor agent's functionality, providing essential tools for compliance and customer interaction while maintaining a structured API design.
207 lines
7.1 KiB
Python
207 lines
7.1 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import inspect
|
|
|
|
import app.api.kyc as kyc_api
|
|
from app.advisor_db import AgentSessionLocal, agent_engine
|
|
from app.main import app
|
|
from app.model.entities import AgentSession
|
|
from app.model.entities_advisor import KycSession
|
|
from app.model.advisor_schemas import AuthContext, KycSessionCreate
|
|
from app.repository.kyc_session_repository import KycSessionRepository
|
|
from app.service.kyc_session_service import KycSessionService
|
|
from app.utils.exceptions import AppError
|
|
|
|
client = TestClient(app)
|
|
|
|
class FakeCoreRepository:
|
|
def get_customer_l0(self, customer_id: str) -> dict | None:
|
|
if customer_id == "CUST-KYC-001":
|
|
return {
|
|
"customer_id": customer_id,
|
|
"display_name": "客户·KYC测试",
|
|
"age": 32,
|
|
}
|
|
return None
|
|
|
|
class AllowOwnership:
|
|
def assert_customer_access(self, auth: AuthContext, customer_id: str) -> None:
|
|
assert auth.advisor_id == "ADV-KYC-001"
|
|
assert customer_id == "CUST-KYC-001"
|
|
|
|
def advisor_context() -> AuthContext:
|
|
return AuthContext(
|
|
user_id="advisor_test",
|
|
display_name="测试顾问",
|
|
roles=["advisor"],
|
|
permissions=["kyc:create", "kyc:chat"],
|
|
trace_id="trace-kyc-session-unit",
|
|
advisor_id="ADV-KYC-001",
|
|
)
|
|
|
|
def session_service() -> KycSessionService:
|
|
return KycSessionService(
|
|
repository=KycSessionRepository(),
|
|
core_repository=FakeCoreRepository(),
|
|
ownership_service=AllowOwnership(),
|
|
)
|
|
|
|
def test_kyc_session_migration_and_creation_associate_agent_session():
|
|
inspector = inspect(agent_engine)
|
|
assert "kyc_session" in inspector.get_table_names()
|
|
columns = {column["name"] for column in inspector.get_columns("kyc_session")}
|
|
assert {
|
|
"session_id",
|
|
"trace_id",
|
|
"advisor_id",
|
|
"customer_id",
|
|
"session_type",
|
|
"status",
|
|
"current_node",
|
|
"collected_fields",
|
|
"missing_fields",
|
|
"progress_pct",
|
|
"dialog_turns",
|
|
"started_at",
|
|
"completed_at",
|
|
"duration_seconds",
|
|
}.issubset(columns)
|
|
|
|
service = session_service()
|
|
result = service.create_session(
|
|
KycSessionCreate(customer_id="CUST-KYC-001", session_type="new_customer"),
|
|
auth=advisor_context(),
|
|
trace_id=f"trace-kyc-create-{uuid4().hex}",
|
|
)
|
|
|
|
assert result.status == "in_progress"
|
|
assert result.current_node == "basic_info"
|
|
assert result.collected_fields == {}
|
|
assert result.progress_pct == 0
|
|
assert result.suggested_question
|
|
assert "年龄" in result.suggested_question
|
|
|
|
with AgentSessionLocal() as session:
|
|
agent_session = (
|
|
session.query(AgentSession)
|
|
.filter(AgentSession.session_id == result.session_id)
|
|
.one()
|
|
)
|
|
assert agent_session.customer_id == "CUST-KYC-001"
|
|
assert agent_session.agent_type == "advisor"
|
|
assert agent_session.metadata_["kyc_session_id"] == result.session_id
|
|
|
|
def test_kyc_session_can_resume_and_complete():
|
|
service = session_service()
|
|
auth = advisor_context()
|
|
created = service.create_session(
|
|
KycSessionCreate(customer_id="CUST-KYC-001", session_type="periodic_review"),
|
|
auth=auth,
|
|
trace_id=f"trace-kyc-resume-{uuid4().hex}",
|
|
)
|
|
|
|
resumed = service.get_session(created.session_id, auth=auth)
|
|
assert resumed.session_id == created.session_id
|
|
assert resumed.status == "in_progress"
|
|
assert resumed.current_node == "basic_info"
|
|
|
|
completed = service.complete_session(
|
|
created.session_id,
|
|
auth=auth,
|
|
trace_id=f"trace-kyc-complete-{uuid4().hex}",
|
|
)
|
|
assert completed.status == "completed"
|
|
assert completed.current_node == "profile_generation"
|
|
assert completed.completed_at is not None
|
|
assert completed.duration_seconds is not None
|
|
|
|
def test_expired_in_progress_session_is_archived_as_abandoned():
|
|
service = session_service()
|
|
auth = advisor_context()
|
|
created = service.create_session(
|
|
KycSessionCreate(customer_id="CUST-KYC-001", session_type="deep_kyc"),
|
|
auth=auth,
|
|
trace_id=f"trace-kyc-expire-create-{uuid4().hex}",
|
|
)
|
|
old_time = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=5)
|
|
with AgentSessionLocal() as session:
|
|
row = session.query(KycSession).filter(KycSession.session_id == created.session_id).one()
|
|
row.started_at = old_time
|
|
row.updated_at = old_time
|
|
session.commit()
|
|
|
|
expired = service.expire_stale_sessions(
|
|
now=datetime.now(timezone.utc),
|
|
ttl_hours=4,
|
|
trace_id=f"trace-kyc-expire-{uuid4().hex}",
|
|
)
|
|
|
|
assert expired == 1
|
|
archived = service.get_session(created.session_id, auth=auth)
|
|
assert archived.status == "abandoned"
|
|
assert archived.completed_at is None
|
|
|
|
def test_completed_session_cannot_be_completed_again():
|
|
service = session_service()
|
|
auth = advisor_context()
|
|
created = service.create_session(
|
|
KycSessionCreate(customer_id="CUST-KYC-001", session_type="new_customer"),
|
|
auth=auth,
|
|
trace_id=f"trace-kyc-repeat-{uuid4().hex}",
|
|
)
|
|
service.complete_session(
|
|
created.session_id,
|
|
auth=auth,
|
|
trace_id=f"trace-kyc-complete-first-{uuid4().hex}",
|
|
)
|
|
|
|
with pytest.raises(AppError) as exc_info:
|
|
service.complete_session(
|
|
created.session_id,
|
|
auth=auth,
|
|
trace_id=f"trace-kyc-complete-second-{uuid4().hex}",
|
|
)
|
|
|
|
assert exc_info.value.code == "40901"
|
|
assert exc_info.value.status_code == 409
|
|
|
|
def test_kyc_session_api_creates_and_resumes_session(monkeypatch):
|
|
monkeypatch.setattr(kyc_api, "kyc_session_service", KycSessionService())
|
|
login = client.post("/api/auth/login", json={"actor_id": "STAFF-10086", "token_type": "staff"})
|
|
token = login.json()["data"]["access_token"]
|
|
trace_id = f"trace-kyc-api-{uuid4().hex}"
|
|
|
|
created = client.post(
|
|
"/api/advisor-agent/kyc/sessions",
|
|
json={
|
|
"customer_id": "CUST-1001",
|
|
"session_type": "new_customer",
|
|
"customer_display_name": "客户·王**",
|
|
},
|
|
headers={"Authorization": f"Bearer {token}", "X-Trace-Id": trace_id},
|
|
)
|
|
|
|
assert created.status_code == 200
|
|
assert created.json()["data"]["status"] == "in_progress"
|
|
assert created.json()["data"]["current_node"] == "basic_info"
|
|
assert created.json()["trace_id"] == trace_id
|
|
|
|
session_id = created.json()["data"]["session_id"]
|
|
resumed = client.get(
|
|
f"/api/advisor-agent/kyc/sessions/{session_id}",
|
|
headers={"Authorization": f"Bearer {token}", "X-Trace-Id": trace_id},
|
|
)
|
|
assert resumed.status_code == 200
|
|
assert resumed.json()["data"]["session_id"] == session_id
|
|
assert resumed.json()["data"]["suggested_question"]
|
|
|
|
def test_kyc_session_api_requires_authentication():
|
|
response = client.post(
|
|
"/api/advisor-agent/kyc/sessions",
|
|
json={"customer_id": "CUST-1001", "session_type": "new_customer"},
|
|
)
|
|
assert response.status_code == 401
|