import uuid from datetime import UTC, datetime import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.core.exceptions import AppException from app.domains.cmdb.models import CmdbCloudScope from app.integrations.models import Integration, PlatformSetting from app.intelligence.cloud_scope_analysis import CLOUD_SCOPE_ANALYSIS_KIND from app.intelligence.cloud_scope_snapshot import build_cloud_scope_snapshot from app.intelligence.models import IntelligenceAnalysis from app.intelligence.provider_registry import BUSINESS_AUDIENCE_KEY from app.mcp import context as mcp_context from app.mcp import tools @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 _seed(db_session, actor_id): now = datetime.now(UTC) integration = Integration( id=uuid.uuid4(), kind="cloud", provider="aws", name="private mcp integration", slug=f"mcp-analysis-{uuid.uuid4().hex[:8]}", enabled=True, is_primary=False, ) db_session.add(integration) await db_session.flush() scope = CmdbCloudScope( integration_id=integration.id, scope_type="aws_account", external_id=f"private-{uuid.uuid4().hex}", display_name="Private MCP scope", status="active", environment="prod", last_synced_at=now, last_owner_confirmed_at=now, ) db_session.add(scope) db_session.add(PlatformSetting(key="intelligence.enabled", value=True)) await db_session.commit() await db_session.refresh(scope) built = await build_cloud_scope_snapshot(db_session, str(scope.id)) analysis = IntelligenceAnalysis( snapshot_id=built.snapshot.id, subject_type="cloud_scope", subject_id=str(scope.id), audience_key=BUSINESS_AUDIENCE_KEY, analysis_kind=CLOUD_SCOPE_ANALYSIS_KIND, facts_hash=built.snapshot.facts_hash, generation_key=uuid.uuid4().hex, status="succeeded", summary_zh="共享结论", summary_en="Shared conclusion", summary_fact_refs=["/facts/resources/total"], conclusions=[], risks=[], opportunities=[], forecasts=[], recommendations=[], data_gaps=[], generated_by_user_id=actor_id, data_as_of=built.snapshot.data_as_of, generated_at=now, created_at=now, updated_at=now, ) db_session.add(analysis) await db_session.commit() return scope, analysis @pytest.mark.asyncio async def test_cloud_scope_mcp_twins_read_and_reuse_same_shared_result( db_session, admin_user, _patch_factory, monkeypatch, ): user, _ = admin_user scope, analysis = await _seed(db_session, user.id) async def forbidden_model(*args, **kwargs): raise AssertionError("MCP unchanged facts must reuse the shared cache") monkeypatch.setattr( "app.domains.bailian.llm_client.BailianLLMClient.chat_json", forbidden_model, ) token = mcp_context.mcp_user_id.set(user.id) try: latest = await tools.cloud_scope_get_ai_analysis(str(scope.id)) history = await tools.cloud_scope_get_ai_analysis_history( str(scope.id), limit="not-a-number", # type: ignore[arg-type] ) generated = await tools.cloud_scope_generate_ai_analysis(str(scope.id)) finally: mcp_context.mcp_user_id.reset(token) assert latest["analysis"]["analysis_id"] == str(analysis.id) assert history["count"] == 1 assert generated["analysis"]["analysis_id"] == str(analysis.id) assert generated["analysis"]["generation"]["cache_reused"] is True assert "reference_map" not in str((latest, history, generated)) count = await db_session.scalar(select(func.count(IntelligenceAnalysis.id))) assert count == 1 @pytest.mark.asyncio async def test_cloud_scope_mcp_generation_marks_cost_and_force_confirmation( db_session, admin_user, _patch_factory, ): user, _ = admin_user scope, _ = await _seed(db_session, user.id) token = mcp_context.mcp_user_id.set(user.id) try: with pytest.raises(AppException) as exc_info: await tools.cloud_scope_generate_ai_analysis( str(scope.id), force=True, confirm_force=False, ) finally: mcp_context.mcp_user_id.reset(token) assert exc_info.value.code == "INTELLIGENCE_FORCE_CONFIRMATION_REQUIRED"