"""Vendor-excluded rows must not re-hit the vendor every sweep. NetSuite refuses any TBA (non-admin) write to an Administrator record → the runner returns ``excluded`` (not failed, so the sweep stays green). But without a guard, the orchestrator would re-issue the identical PATCH / deactivate on every subsequent sweep and re-collect the same 400 — pure churn against a handful of manually-maintained admin records. These tests pin the two guards (service.py): 1. UPSERT: an excluded row records ``last_payload_hash``; the unchanged-payload short-circuit then re-affirms it as ``excluded`` without another vendor call. 2. DEACTIVATE: an already-``excluded`` out-of-scope row is skipped before the vendor call. """ from __future__ import annotations import uuid import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.identity_sync.models import ( IdentitySyncState, IdentitySyncTarget, ) from app.domains.identity_sync.presets import NAVAN_MAPPING_RULES from app.domains.identity_sync.runners.base import PersonOutcome, SyncRunner from app.domains.identity_sync.service import IdentitySyncService from app.identity.person_models import IdentityPerson class _ExcludingRunner(SyncRunner): """Counts vendor calls. ``deactivate`` always returns ``excluded``; ``upsert`` returns ``excluded`` only when ``exclude_upsert`` (so the deactivate test can upsert successfully first, then exclude on leave).""" supports_deactivate = True supports_delete = True def __init__(self, *, exclude_upsert: bool = True) -> None: self.exclude_upsert = exclude_upsert self.upsert_calls = 0 self.deactivate_calls = 0 async def upsert(self, *, person_email, payload, prior_external_id): self.upsert_calls += 1 if self.exclude_upsert: return PersonOutcome( status="excluded", external_id="ns-131", detail={"reason": "netsuite_admin_record_locked"}, ) return PersonOutcome(status="success", external_id="ns-200") async def deactivate(self, *, person_email, prior_external_id): self.deactivate_calls += 1 return PersonOutcome( status="excluded", detail={"reason": "netsuite_admin_record_locked"}, ) async def delete(self, *, person_email, prior_external_id): # pragma: no cover return PersonOutcome(status="excluded") def describe(self) -> str: return "excluding-stub" async def _make_person( db: AsyncSession, *, email: str, status: str = "active", ) -> IdentityPerson: person = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0].title(), employment_status=status, source_system="successfactors", hris_person_id=f"sf-{email}", is_china_employee=False, country="USA", company_name="01_PingCAP", ) db.add(person) await db.flush() return person async def _make_target( db: AsyncSession, *, statuses: list[str], ) -> IdentitySyncTarget: tgt = IdentitySyncTarget( id=uuid.uuid4(), name="excl", slug=f"excl-{uuid.uuid4().hex[:8]}", kind="navan", enabled=True, on_offboard="deactivate", mapping_config={"rules": list(NAVAN_MAPPING_RULES)}, filter_config={ "include_offboarded": False, "departments": [], "include_subtree": True, "countries": ["USA"], "employment_statuses": statuses, "job_title_includes": [], "person_ids": [], "mode": "and", }, schedule_config={"trigger": "manual", "cron_expr": None, "timezone": "UTC"}, runner_options={ "base_url": "https://app.navan.com/universal/scim/v2", "auth_scheme": "bearer", "extra_headers": {}, "verify_ssl": True, "timeout_seconds": 30, "rate_limit_per_minute": 60, }, ) db.add(tgt) await db.flush() return tgt @pytest.mark.asyncio async def test_excluded_upsert_not_reattempted_next_sweep(db_session: AsyncSession): await _make_person(db_session, email="ns.admin@pingcap.com") target = await _make_target(db_session, statuses=["active"]) await db_session.commit() runner = _ExcludingRunner() svc = IdentitySyncService(db_session) s1, _ = await svc._sweep(target=target, runner=runner) await svc.db.commit() assert s1.excluded == 1 assert runner.upsert_calls == 1 # Second sweep: payload unchanged + last_status excluded → no vendor call. s2, _ = await svc._sweep(target=target, runner=runner) await svc.db.commit() assert s2.excluded == 1 # still surfaced as excluded assert runner.upsert_calls == 1 # NOT re-attempted state = (await db_session.execute( select(IdentitySyncState).where( IdentitySyncState.target_id == target.id, ) )).scalars().first() assert state.last_status == "excluded" assert state.last_payload_hash is not None # hash recorded → short-circuit @pytest.mark.asyncio async def test_excluded_deactivate_not_reattempted_next_sweep( db_session: AsyncSession, ): # In-scope first so a state row exists, then the person leaves → falls # out of scope → deactivate path → excluded → must not re-attempt. person = await _make_person(db_session, email="ns.leaver@pingcap.com") target = await _make_target(db_session, statuses=["active"]) await db_session.commit() # Upsert succeeds first (otherwise the upsert-exclusion would mark the # row excluded before it ever leaves scope, and the guard would skip # for that reason instead). The deactivate is what excludes here. runner = _ExcludingRunner(exclude_upsert=False) svc = IdentitySyncService(db_session) await svc._sweep(target=target, runner=runner) await svc.db.commit() # Person falls out of scope via the countries filter (USA-only). We use # country rather than employment_status="left" deliberately: a global # LeaverBlocked before_flush listener (registered by sibling test # modules) would otherwise fire on "left" — and out-of-scope is all this # test needs to reach the deactivate path. person.country = "CHN" person.is_china_employee = True await db_session.commit() d1, _ = await svc._sweep(target=target, runner=runner) await svc.db.commit() assert runner.deactivate_calls == 1 assert d1.excluded == 1 d2, _ = await svc._sweep(target=target, runner=runner) await svc.db.commit() # Already excluded → skipped before the vendor call, not re-counted. assert runner.deactivate_calls == 1 @pytest.mark.asyncio async def test_push_only_skips_out_of_scope_deactivation(db_session: AsyncSession): """push-only mode (manual / test syncs): a previously-synced person who falls OUT of the current filter scope must NOT be deactivated — only a full reconcile (push_only=False, the scheduled cron) does that. This is what lets an admin sync any scoped subset for testing without collateral deactivation of everyone else already synced. """ person = await _make_person(db_session, email="pushonly.leaver@pingcap.com") target = await _make_target(db_session, statuses=["active"]) await db_session.commit() runner = _ExcludingRunner(exclude_upsert=False) # upsert succeeds; counts deactivate svc = IdentitySyncService(db_session) # In-scope sync → state row created, no deactivate. await svc._sweep(target=target, runner=runner) await svc.db.commit() assert runner.deactivate_calls == 0 # Person falls OUT of scope — change work country (filter is # countries=["USA"]) rather than employment_status (which has a # Leaver-Blocked guard that refuses a direct flip to 'left'). person.country = "JPN" await db_session.commit() # push_only=True → out-of-scope person is NOT deactivated. await svc._sweep(target=target, runner=runner, push_only=True) await svc.db.commit() assert runner.deactivate_calls == 0 # skipped — no collateral # push_only=False (full reconcile) → the genuine leaver IS deactivated. await svc._sweep(target=target, runner=runner, push_only=False) await svc.db.commit() assert runner.deactivate_calls == 1 def test_run_target_manual_is_push_only_by_default(): """Default policy: manual/event runs are push-only (safe for scoped testing); the scheduler does a full reconcile. Pins the trigger_source → push_only mapping so a refactor can't silently make manual runs start deactivating out-of-scope people again.""" def _default(trigger_source: str) -> bool: return trigger_source.startswith(("manual", "event")) assert _default("manual:abc") is True assert _default("manual:mcp:abc") is True assert _default("event:hire") is True assert _default("scheduler") is False