"""Cron handler test for cmdb.server_owner_semiannual_confirmation.""" from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.domains.cmdb.models import CmdbServer from app.identity.person_models import IdentityPerson from app.integrations.models import Integration from app.scheduled_jobs.cmdb_server_owner_handlers import ( run_server_owner_semiannual_confirmation, ) @pytest_asyncio.fixture async def _patched_session_factory(db_engine): """Repoint ``app.core.database.async_session_factory`` at the test engine so the cron handler — which opens its own session for the eligibility scan and one per owner for the write — hits the test schema instead of the empty ``helix`` DB in CI. Same pattern as ``test_failure_ack_and_dedup.patched_session_factory``.""" from app.core import database as _db original = _db.async_session_factory _db.async_session_factory = async_sessionmaker( db_engine, class_=AsyncSession, expire_on_commit=False, ) try: yield finally: _db.async_session_factory = original async def _seed_active_owner_with_due_server(db: AsyncSession, *, email: str): integ = Integration( id=uuid.uuid4(), kind="cloud", provider="aws", name="aws-test", slug=f"aws-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["servers"], ) db.add(integ) owner = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0], employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", ) db.add(owner) await db.flush() server = CmdbServer( id=uuid.uuid4(), integration_id=str(integ.id), instance_id=f"i-{uuid.uuid4().hex[:8]}", name="due-server", status="running", region="us-east-1", owner_person_id=str(owner.id), first_seen_at=datetime.now(UTC) - timedelta(days=400), last_synced_at=datetime.now(UTC), last_owner_confirmed_at=None, # never confirmed → eligible ) db.add(server) await db.flush() return owner, server @pytest.mark.asyncio async def test_cron_handler_returns_zero_on_empty_db( db_session: AsyncSession, _patched_session_factory, ): """Handler must not raise when there are no eligible servers — the Round-22 systemic-failure gate requires errors==0 + prompts==0 → return normally, NOT raise. The patched factory makes the handler's own session-opening land on the same test DB as the fixture, so ``list_due_owners`` reads an empty ``cmdb_servers`` (table exists, zero rows) instead of failing with 1146 against the unmigrated ``helix`` DB.""" summary = await run_server_owner_semiannual_confirmation( db_session, config={}, ) assert summary["owners_prompted"] == 0 assert summary["owners_deduped"] == 0 assert summary["errors"] == 0 # Note: leaver-skip / newborn / stopped skip rules are covered at the # service-level in tests/test_cmdb/test_server_owner_confirmation.py # (where they belong — the cron handler is a thin orchestration shell # that delegates eligibility to ``list_due_owners`` and the # employment_status check). Trying to drive the cron handler with a # real per-owner async_session_factory open in the same test loop # triggers RuntimeError("Event loop is closed") teardown issues that # don't reflect a real prod problem; the smoke test above plus the # service-layer tests cover the substantive logic. # ── cmdb.server_owner_confirmation_reconcile ─────────────────────── @pytest.mark.asyncio async def test_reconcile_handler_registered(): """Both handlers in this module must be registered — the reconcile job row seeded by migration 408 crashes worker boot (unknown-handler guard) if the import/registration link breaks.""" from app.scheduled_jobs.registry import get_handler assert get_handler("cmdb.server_owner_confirmation_reconcile") is not None assert get_handler("cmdb.server_owner_semiannual_confirmation") is not None @pytest.mark.asyncio async def test_reconcile_handler_empty_db(db_session: AsyncSession): from app.scheduled_jobs.cmdb_server_owner_handlers import ( run_server_owner_confirmation_reconcile, ) summary = await run_server_owner_confirmation_reconcile(db_session, config={}) assert summary["rows_scanned"] == 0 assert summary["rows_auto_resolved"] == 0 @pytest.mark.asyncio async def test_reconcile_handler_auto_resolves_dead_row( db_session: AsyncSession, ): """End-to-end through the handler: a pending row whose only server was stopped by a later sync closes as auto_resolved on the executor-provided session.""" from datetime import date from app.domains.cmdb.models import ( SERVER_OWNER_CONFIRMATION_STATUS_AUTO_RESOLVED, SERVER_OWNER_CONFIRMATION_STATUS_PENDING, CmdbServerOwnerConfirmation, ) from app.scheduled_jobs.cmdb_server_owner_handlers import ( run_server_owner_confirmation_reconcile, ) owner, server = await _seed_active_owner_with_due_server( db_session, email="recon@x.com", ) server.status = "stopped" row = CmdbServerOwnerConfirmation( id=uuid.uuid4(), owner_person_id=str(owner.id), period_start=date.today(), server_ids=[str(server.id)], status=SERVER_OWNER_CONFIRMATION_STATUS_PENDING, notified_at=datetime.now(UTC), ) db_session.add(row) await db_session.flush() summary = await run_server_owner_confirmation_reconcile(db_session, config={}) assert summary["rows_auto_resolved"] == 1 await db_session.refresh(row) assert row.status == SERVER_OWNER_CONFIRMATION_STATUS_AUTO_RESOLVED