"""End-to-end test for ``SuccessFactorsLifecycleService.run_scheduled_full``. The new ``hris.full_sync`` cron (migration 230) closes the 30-day incremental blind spot that left wangdi P0175 (terminated 2026-03-20, 73 days ago by 2026-06-01) invisible in Helix. ``run_scheduled_full`` is the scheduler entry point — preview + auto-confirm in one call. Pins: 1. ``run_scheduled_full`` calls ``preview_full_employee_sync`` then ``confirm_batch`` and returns ``auto_confirmed=True``. 2. The batch's ``trigger_source`` is flipped to ``'scheduled'`` (default is ``'manual'`` on ``preview_full_employee_sync``) so admin batch list can distinguish cron-driven runs. 3. Historical leaver (wangdi-shape) lands as ``employment_status='left'`` on identity_persons via the same _upsert_person path the existing test_full_sync_forfeited.py pins for the manual entry point. 4. ``auto_confirm=False`` returns the staged batch in ``pending_review`` without confirming — preserves manual-review escape hatch. """ from __future__ import annotations from datetime import date import pytest from sqlalchemy import select from app.domains.hr.models import HrSyncBatch from app.domains.hr.service import SuccessFactorsLifecycleService from app.domains.leaver_blocked.listener import ( register_listener as _register_leaver_listener, ) from app.identity.audit_listener import ( register_listener as _register_identity_audit_listener, ) from app.identity.person_models import IdentityPerson @pytest.fixture(autouse=True) def _wire_prod_listeners(): _register_leaver_listener() _register_identity_audit_listener() class _FakeFullSyncClient: """Same shape as ``test_full_sync_forfeited._FakeFullSyncClient`` — one active + one wangdi-shape historical leaver. Kept self-contained here so this file is greppable for ``run_scheduled_full`` without cross-file fixture hunting. """ async def list_active_companies(self) -> list[dict]: return [] async def list_active_departments(self) -> list[dict]: return [] async def list_active_locations(self) -> list[dict]: return [] async def list_active_positions(self) -> list[dict]: return [] async def list_all_users(self) -> list[dict]: return [ { "userId": "P_ACTIVE", "displayName": "Active Person", "email": "active@pingcap.com", "empId": "P_ACTIVE", "status": "t", "hireDate": "/Date(1700000000000)/", "lastModifiedDateTime": "/Date(1700000000000+0000)/", }, { "userId": "P0175", "displayName": "王迪 Di Wang", "email": "wangdi@pingcap.com", "empId": "P0175", "status": "f", "hireDate": "/Date(1572220800000)/", "lastModifiedDateTime": "/Date(1776514800000+0000)/", }, ] async def get_employments_for_users(self, user_ids: list[str]) -> list[dict]: if "P0175" not in user_ids: return [] return [{ "personIdExternal": "P0175", "userId": "P0175", "startDate": "/Date(1572220800000)/", "endDate": "/Date(1773964800000)/", # 2026-03-20 "originalStartDate": "/Date(1572220800000)/", "lastModifiedDateTime": "/Date(1776514800000+0000)/", }] async def get_jobs_for_users(self, user_ids: list[str]) -> list[dict]: return [] @pytest.mark.asyncio async def test_run_scheduled_full_auto_confirms_and_lands_historical_leaver( db_session, ) -> None: """The cron path must preview + auto-confirm + land wangdi as ``employment_status='left'`` in one call. """ service = SuccessFactorsLifecycleService( db_session, sf_client=_FakeFullSyncClient(), today=date(2026, 6, 1), ) result = await service.run_scheduled_full( triggered_by="system:scheduler:test", auto_confirm=True, ) assert result["auto_confirmed"] is True assert result.get("status") in {"confirmed", "success"} assert result.get("error_count", 0) == 0 # Batch must be marked as scheduled-source so the admin list can # distinguish cron runs from manual ones. batch = (await db_session.execute( select(HrSyncBatch).where(HrSyncBatch.id == result["batch_id"]) )).scalar_one() assert batch.trigger_source == "scheduled" assert batch.sync_mode == "full" wangdi = (await db_session.execute( select(IdentityPerson).where(IdentityPerson.hris_person_id == "P0175") )).scalar_one() assert wangdi.employment_status == "left" assert wangdi.work_email == "wangdi@pingcap.com" assert wangdi.termination_date == date(2026, 3, 20) @pytest.mark.asyncio async def test_run_scheduled_full_respects_auto_confirm_false( db_session, ) -> None: """``auto_confirm=False`` must leave the batch in ``pending_review`` for manual operator action — preserves the safety hatch for special cases. """ service = SuccessFactorsLifecycleService( db_session, sf_client=_FakeFullSyncClient(), today=date(2026, 6, 1), ) result = await service.run_scheduled_full( triggered_by="test", auto_confirm=False, ) assert result["auto_confirmed"] is False batch = (await db_session.execute( select(HrSyncBatch).where(HrSyncBatch.id == result["batch_id"]) )).scalar_one() assert batch.status == "pending_review" assert batch.trigger_source == "scheduled" # No identity_persons row written for wangdi yet. wangdi = (await db_session.execute( select(IdentityPerson).where(IdentityPerson.hris_person_id == "P0175") )).scalar_one_or_none() assert wangdi is None