import json import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.exceptions import AppException from app.integrations.models import PlatformSetting from app.intelligence.models import IntelligenceRecommendation, IntelligenceSnapshot from app.mcp import context as mcp_context from app.mcp import tools from app.mcp.runtime import serialize from app.scheduled_jobs.intelligence_handlers import run_intelligence_snapshot_daily @pytest.fixture() def _patch_factory(db_engine, monkeypatch): from app.core import database as database monkeypatch.setattr( database, "async_session_factory", async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False), ) async def _enable(db_session): db_session.add(PlatformSetting(key="intelligence.enabled", value=True)) await db_session.commit() @pytest.mark.asyncio async def test_intelligence_mcp_twins_share_read_contract( db_session, admin_user, _patch_factory, ): await _enable(db_session) await run_intelligence_snapshot_daily(db_session, {}) user, _ = admin_user token = mcp_context.mcp_user_id.set(user.id) try: overview = await tools.intelligence_get_overview() domain = await tools.intelligence_get_domain("people_org") history = await tools.intelligence_get_history(limit=10_000) finally: mcp_context.mcp_user_id.reset(token) assert overview["model_calls"] == 0 assert len(overview["dimensions"]) == 8 assert domain["domain"] == "people_org" assert domain["model_calls"] == 0 assert domain["metric_catalog"][0]["key"] == "people_org.hris_freshness" assert history["limit"] == 365 assert history["model_calls"] == 0 assert "audience_key" not in str((overview, domain, history)) assert "reference_map" not in str((overview, domain, history)) # MCP's wire path JSON-encodes the runtime projection. Keep a regression # around Decimal/date handling instead of only testing direct Python calls. encoded = json.dumps(serialize({ "overview": overview, "domain": domain, "history": history, })) assert '"model_calls": 0' in encoded @pytest.mark.asyncio async def test_employee_is_refused_by_intelligence_mcp( db_session, employee_user, _patch_factory, ): await _enable(db_session) user, _ = employee_user token = mcp_context.mcp_user_id.set(user.id) try: with pytest.raises(AppException) as exc_info: await tools.intelligence_get_overview() finally: mcp_context.mcp_user_id.reset(token) assert exc_info.value.status_code == 403 @pytest.mark.asyncio async def test_intelligence_mcp_execute_records_the_same_safe_handoff_as_http( db_session, admin_user, _patch_factory, ): """The MCP twin must not falsely claim a workflow conflict after HTTP starts it.""" await _enable(db_session) await run_intelligence_snapshot_daily(db_session, {}) snapshot = (await db_session.execute( select(IntelligenceSnapshot).order_by(IntelligenceSnapshot.created_at.desc()).limit(1), )).scalar_one() recommendation = IntelligenceRecommendation( snapshot_id=snapshot.id, subject_type=snapshot.subject_type, subject_id=snapshot.subject_id, audience_key=snapshot.audience_key, dedupe_key="mcp-handoff-parity", domain="reclaim", severity="high", priority=1, title_zh="回收闲置资源", title_en="Reclaim idle resource", summary_zh="", summary_en="", evidence_refs=[], action_contract={ "contract_version": 1, "action_kind": "reclaim", "fact_refs": ["/facts/assets/idle_surplus_count"], "verification": {"baseline_facts_hash": snapshot.facts_hash}, }, status="new", ) db_session.add(recommendation) await db_session.commit() user, _ = admin_user token = mcp_context.mcp_user_id.set(user.id) try: result = await tools.intelligence_execute_recommendation(str(recommendation.id)) finally: mcp_context.mcp_user_id.reset(token) assert result["status"] == "in_progress" assert result["model_calls"] == 0 assert result["handoff"]["workbench_path"] == "/admin/assets" assert result["handoff"]["handling_steps"] == [ "review_evidence", "open_workbench", "verify_snapshot", ] @pytest.mark.asyncio async def test_mcp_update_recommendation_matches_http_status_and_message( client, db_session, admin_user, _patch_factory, ): """The MCP twin must answer exactly what the HTTP handler answers. Two drifts (audit finding D1): the tool parsed the UUID together with the Decimals BEFORE fetching the row, so a malformed id returned 422 where HTTP returns 404 and an unauthorized caller learned that its argument parsed; and the two savings checks shared one "Savings cannot be negative." string, collapsing HTTP's two distinct messages so an agent could not tell which field it got wrong. """ await _enable(db_session) await run_intelligence_snapshot_daily(db_session, {}) snapshot = (await db_session.execute( select(IntelligenceSnapshot) .where(IntelligenceSnapshot.subject_type == "platform") .order_by(IntelligenceSnapshot.created_at.desc()).limit(1), )).scalars().first() visible = IntelligenceRecommendation( snapshot_id=snapshot.id, subject_type=snapshot.subject_type, subject_id=snapshot.subject_id, audience_key=snapshot.audience_key, dedupe_key="mcp-http-parity-visible", domain="reclaim", severity="high", priority=1, title_zh="回收", title_en="Reclaim", summary_zh="", summary_en="", evidence_refs=[], action_contract={"contract_version": 2, "action_kind": "reclaim", "fact_refs": []}, status="in_progress", ) invisible = IntelligenceRecommendation( snapshot_id=snapshot.id, subject_type="cloud_scope", subject_id="00000000-0000-0000-0000-0000000000ff", audience_key=snapshot.audience_key, dedupe_key="mcp-http-parity-invisible", domain="reclaim", severity="high", priority=1, title_zh="回收", title_en="Reclaim", summary_zh="", summary_en="", evidence_refs=[], action_contract={"contract_version": 2, "action_kind": "reclaim", "fact_refs": []}, status="new", ) db_session.add_all([visible, invisible]) await db_session.commit() user, token = admin_user headers = {"Authorization": f"Bearer {token}"} cases = [ ("not-a-uuid", {"status": "viewed"}), (str(invisible.id), {"status": "viewed"}), (str(visible.id), {"status": "viewed", "confirmed_savings": "-1"}), (str(visible.id), {"status": "resolved", "realized_savings": "-1"}), ] for recommendation_id, payload in cases: http = await client.patch( f"/api/v1/intelligence/recommendations/{recommendation_id}", json=payload, headers=headers, ) mcp_token = mcp_context.mcp_user_id.set(user.id) try: with pytest.raises(AppException) as caught: await tools.intelligence_update_recommendation( recommendation_id, **payload, ) finally: mcp_context.mcp_user_id.reset(mcp_token) error = http.json()["errors"][0] assert caught.value.status_code == http.status_code, (recommendation_id, payload) assert caught.value.code == error["code"], (recommendation_id, payload) assert caught.value.message == error["message"], (recommendation_id, payload) # And the two savings messages stay distinguishable from one another. assert ( await _mcp_error(user, str(visible.id), {"status": "viewed", "confirmed_savings": "-1"}) ) == "Confirmed savings cannot be negative." assert ( await _mcp_error(user, str(visible.id), {"status": "resolved", "realized_savings": "-1"}) ) == "Realized savings cannot be negative." async def _mcp_error(user, recommendation_id: str, payload: dict) -> str: token = mcp_context.mcp_user_id.set(user.id) try: with pytest.raises(AppException) as caught: await tools.intelligence_update_recommendation(recommendation_id, **payload) finally: mcp_context.mcp_user_id.reset(token) return caught.value.message