"""Tests for ``run_resource_sync`` error propagation. Regression (2026-04-24): the handler used to return ``{"status": "failed", ...}`` silently. The outer scheduled_job runner only marks a run ``failed`` when the handler RAISES, so admins saw the CMDB sync execution history row as 成功 (green) while the inner capability result said "failed" — misleading. Fix: raise so the run row, ``last_status`` / ``last_error`` on the scheduled_job, and the failure counter all reflect reality. """ from __future__ import annotations import uuid from contextlib import asynccontextmanager from unittest.mock import AsyncMock, patch import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import CmdbSyncJob from app.integrations.models import Integration async def _create_cloud_integration( db: AsyncSession, *, provider: str = "azure", capabilities: list[str] | None = None, ) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider=provider, name=f"{provider}-test", slug=f"{provider}-test-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=capabilities or ["servers"], ) db.add(row) await db.commit() return row def _patch_session_factory(db_session: AsyncSession): """Make every ``async with async_session_factory()`` inside the handler yield the test's ``db_session``. Wrapped as an async context manager so drop-in replaces the real factory. """ @asynccontextmanager async def _factory(): yield db_session # The handler imports the factory inside the function body, so # the source module is the only stable patch target. return [ patch( "app.core.database.async_session_factory", new=_factory, ), ] @pytest.mark.asyncio async def test_handler_raises_when_capability_result_is_failed( db_session: AsyncSession, ) -> None: """If ``sync.run_sync`` lands the inner CmdbSyncJob on status=failed, the handler must raise so the scheduled_job_runs row reflects the failure.""" from app.scheduled_jobs.handlers import run_resource_sync integration = await _create_cloud_integration(db_session) async def _fake_run_sync(*, integration_id, job_id, capabilities, triggered_by_user_id): # Simulate the CMDB sync engine writing a ``failed`` row — this # is what the real engine does when all upserts error out. from sqlalchemy import update await db_session.execute( update(CmdbSyncJob) .where(CmdbSyncJob.id == uuid.UUID(job_id)) .values( status="failed", total_upserted=0, total_deleted=0, total_errors=1, capability_results={ "servers": { "status": "failed", "error": "Data too long for column 'instance_id'", }, }, ) ) await db_session.commit() patches = _patch_session_factory(db_session) for p in patches: p.start() try: with patch( "app.domains.cmdb.sync.run_sync", new=AsyncMock(side_effect=_fake_run_sync), ): with pytest.raises(RuntimeError, match="instance_id"): await run_resource_sync( db_session, { "integration_id": str(integration.id), "capability": "servers", }, ) finally: for p in patches: p.stop() @pytest.mark.asyncio async def test_handler_returns_summary_when_capability_succeeds( db_session: AsyncSession, ) -> None: """Happy path: if the CmdbSyncJob lands on status=success, the handler returns the summary without raising.""" from app.scheduled_jobs.handlers import run_resource_sync integration = await _create_cloud_integration(db_session) async def _fake_run_sync(*, integration_id, job_id, capabilities, triggered_by_user_id): from sqlalchemy import update await db_session.execute( update(CmdbSyncJob) .where(CmdbSyncJob.id == uuid.UUID(job_id)) .values( status="success", total_upserted=5, total_deleted=0, total_errors=0, capability_results={"servers": {"status": "success", "upserted": 5}}, ) ) await db_session.commit() patches = _patch_session_factory(db_session) for p in patches: p.start() try: with patch( "app.domains.cmdb.sync.run_sync", new=AsyncMock(side_effect=_fake_run_sync), ): result = await run_resource_sync( db_session, { "integration_id": str(integration.id), "capability": "servers", }, ) finally: for p in patches: p.stop() assert result["status"] == "success" assert result["upserted"] == 5 assert result["errors"] == 0