"""Regression tests for per-capability sync lock (2026-04-24). Previously the ``_run_sync_impl`` guard failed any sync where ANOTHER job for the same integration was already in ``status='running'`` — but the per-capability scheduled_jobs design (migration 046) deliberately creates one scheduled_job per capability, all firing on the same cron minute. Result: first-to-start wins, every other capability bails with ``status='failed'``. With the handler's "raise on failed" change (2026-04-24), that silently-broken behaviour started surfacing as red cells in the admin UI. Fix: lock on (integration_id, status='running', JSON_OVERLAPS(capabilities)). Distinct-capability jobs coexist; overlap (e.g. ``*``-wildcard manual full-sync alongside per-cap cron) still serializes. Also: the bail path lands on ``status='skipped'``, not ``'failed'``, so this benign race never flashes red. """ from __future__ import annotations import uuid from datetime import UTC, datetime from unittest.mock import patch import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import CmdbSyncJob from app.domains.cmdb.sync import ( _build_capability_overlap_filter, run_sync, ) from app.integrations.models import Integration # ── Pure helper unit test ─────────────────────────────────────── def test_capability_overlap_filter_wildcard_returns_none() -> None: assert _build_capability_overlap_filter([]) is None assert _build_capability_overlap_filter(["*"]) is None def test_capability_overlap_filter_builds_sql_for_concrete_caps() -> None: expr = _build_capability_overlap_filter(["cdn"]) assert expr is not None # We don't serialize/execute here; just ensure the text + bind name # match the runtime SQL contract. assert "JSON_OVERLAPS" in str(expr) assert expr.compile().params.get("caps") == '["cdn"]' # ── Integration-level regression via run_sync ─────────────────── async def _make_integration(db: AsyncSession) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider="aws", name="aws-sg-test", slug=f"aws-sg-test-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["cdn", "domains", "dns_records", "iam"], ) db.add(row) await db.commit() return row async def _queue_job( db: AsyncSession, integration_id: str, resource_types: list[str], status: str = "running", ) -> CmdbSyncJob: """Insert a sync job and leave it in ``status='running'`` so the lock guard has something to react to.""" job = CmdbSyncJob( id=uuid.uuid4(), integration_id=integration_id, triggered_by="scheduled", triggered_by_user_id=None, resource_types=resource_types, status=status, started_at=datetime.now(UTC), ) db.add(job) await db.commit() return job @pytest.fixture def _patched_session_factory(db_session: AsyncSession): """Pipe ``async_session_factory`` into the test session so the guard's fresh-session lookups see the fixture rows.""" from contextlib import asynccontextmanager @asynccontextmanager async def _factory(): yield db_session # ``run_sync`` imports the factory at module load time, so the # source module is the only stable patch target. return patch( "app.domains.cmdb.sync.async_session_factory", new=_factory, ) @pytest.mark.asyncio async def test_disjoint_capabilities_do_not_serialize( db_session: AsyncSession, _patched_session_factory, ) -> None: """Two jobs on the same integration but DIFFERENT capabilities must BOTH run — the per-integration lock must not block them.""" integration = await _make_integration(db_session) # Pre-existing "running" job for a different capability. await _queue_job( db_session, str(integration.id), ["domains"], ) # New job for ``cdn`` — should NOT hit the skipped branch. new_job = await _queue_job( db_session, str(integration.id), ["cdn"], status="queued", ) new_job_id = str(new_job.id) with _patched_session_factory: # ``_run_sync_impl`` runs through the lock guard; after that # it'd try to load the integration + decrypt secrets. We only # care whether the guard bailed, so stub ``_load_cloud_driver`` # -equivalent by patching the secrets check. Simpler: monitor # the status after the guard by swapping in a raise that # happens AFTER the guard (if it didn't short-circuit, we'll # hit a KeyError in the secrets path which we then verify is # NOT a 'skipped' state). with patch( "app.domains.cmdb.credential_crypto.decrypt_fields", side_effect=RuntimeError("stop after lock guard"), ): await run_sync( integration_id=str(integration.id), job_id=new_job_id, capabilities=["cdn"], triggered_by_user_id=None, ) refreshed = ( await db_session.execute( select(CmdbSyncJob).where( CmdbSyncJob.id == uuid.UUID(new_job_id), ) ) ).scalars().one() # Guard did NOT short-circuit (would have set status='skipped'). # The job went past the lock; then hit the decrypt stub → the # outer catch in ``run_sync`` marks it ``failed`` with that error. assert refreshed.status != "skipped", ( "disjoint capabilities must not be blocked by the per-integration lock" ) @pytest.mark.asyncio async def test_overlapping_capabilities_skip_not_fail( db_session: AsyncSession, _patched_session_factory, ) -> None: """Two jobs on the same integration with OVERLAPPING capabilities serialize — second one lands on ``status='skipped'`` (NOT ``'failed'``). This guarantees scheduled-job history stays green on benign races.""" integration = await _make_integration(db_session) await _queue_job( db_session, str(integration.id), ["cdn", "domains"], ) new_job = await _queue_job( db_session, str(integration.id), ["cdn"], status="queued", ) new_job_id = str(new_job.id) with _patched_session_factory: await run_sync( integration_id=str(integration.id), job_id=new_job_id, capabilities=["cdn"], triggered_by_user_id=None, ) refreshed = ( await db_session.execute( select(CmdbSyncJob).where( CmdbSyncJob.id == uuid.UUID(new_job_id), ) ) ).scalars().one() assert refreshed.status == "skipped", refreshed.status assert "Another sync" in (refreshed.error_message or "") # Counts stay at zero — nothing was synced. assert refreshed.total_errors == 0 assert refreshed.total_upserted == 0 @pytest.mark.asyncio async def test_wildcard_run_locks_everything( db_session: AsyncSession, _patched_session_factory, ) -> None: """``capabilities=["*"]`` (expand-to-all) should conflict with ANY other running job on the integration — because a wildcard run covers every resource type. Restores the original pre-047 semantics for full-sync jobs.""" integration = await _make_integration(db_session) await _queue_job( db_session, str(integration.id), ["cdn"], ) new_job = await _queue_job( db_session, str(integration.id), ["*"], status="queued", ) new_job_id = str(new_job.id) with _patched_session_factory: await run_sync( integration_id=str(integration.id), job_id=new_job_id, capabilities=["*"], triggered_by_user_id=None, ) refreshed = ( await db_session.execute( select(CmdbSyncJob).where( CmdbSyncJob.id == uuid.UUID(new_job_id), ) ) ).scalars().one() assert refreshed.status == "skipped"