"""汇联易 dept push re-parents Helix-root BUs under the vendor PingCAP root. SF models 13 top-level BUs with NO "PingCAP" department (it's only the ``cust_L0`` company label), so Helix's org tree has those BUs as roots (parent=None). The 汇联易 tenant has a real top-of-tree dept ``custDeptNumber=RootOrg`` named "PingCAP" (verified live 2026-06-16). The dept phase therefore re-parents each Helix-root BU under that vendor root code instead of sending parent=None — keeping the vendor tree single-rooted and mirroring the legacy structure the 费控 config hangs off of. These tests drive ``_huilianyi_dept_phase_body`` with a capturing fake runner and assert the ``parent_dept_code`` each dept is pushed with. """ from __future__ import annotations import uuid import pytest from app.domains.identity_sync.models import IdentitySyncTarget from app.domains.identity_sync.runners.base import DeptOutcome from app.domains.identity_sync.service import ( HUILIANYI_DEFAULT_ROOT_DEPT_CODE, IdentitySyncService, RunSummary, ) from app.identity.person_models import ( IdentityOrgMembership, IdentityOrgNode, IdentityPerson, ) # Root BU (Helix top-level, parent=None) + a child dept under it. _ROOT_BU = "2730378" # Business Operations _CHILD = "3963958" # IT Services & Security (child of Business Operations) class _CapturingDeptRunner: """Records every upsert_dept(dept_code → parent_dept_code).""" def __init__(self) -> None: self.calls: dict[str, str | None] = {} async def upsert_dept(self, *, dept_code, parent_dept_code, **_kwargs): self.calls[dept_code] = parent_dept_code return DeptOutcome(status="success", external_id=dept_code) async def disable_dept(self, *, dept_code, **_kwargs): return DeptOutcome(status="soft_deleted", external_id=dept_code) async def aclose(self) -> None: return None async def _seed_tree(db) -> None: """Two-node tree (root BU + child) with one in-scope member in the child so ``_compute_inscope_dept_ids`` admits both.""" db.add_all([ IdentityOrgNode( id=uuid.uuid4(), org_node_source_id=_ROOT_BU, org_node_name="Business Operations", parent_org_node_source_id=None, org_level=1, org_path="PingCAP / Business Operations", node_status="active", ), IdentityOrgNode( id=uuid.uuid4(), org_node_source_id=_CHILD, org_node_name="IT Services & Security", parent_org_node_source_id=_ROOT_BU, org_level=2, org_path="PingCAP / Business Operations / IT Services & Security", node_status="active", ), ]) person = IdentityPerson( id=uuid.uuid4(), source_system="successfactors", hris_person_id="P0989", employee_no="P0989", work_email="shuai.feng@pingcap.com", display_name="冯帅", employment_status="active", ) db.add(person) await db.flush() db.add(IdentityOrgMembership( id=uuid.uuid4(), person_id=person.id, org_node_source_id=_CHILD, org_node_name="IT Services & Security", parent_org_node_source_id=_ROOT_BU, is_primary=True, org_path="PingCAP / Business Operations / IT Services & Security", )) await db.flush() def _target(runner_options: dict) -> IdentitySyncTarget: return IdentitySyncTarget( id=uuid.uuid4(), name="hly", slug="hly-root-test", kind="huilianyi", enabled=False, on_offboard="deactivate", mapping_config={"rules": []}, filter_config={ "employee_no_pattern": r"^P\d+$", "employment_statuses": ["active"], "include_subtree": True, }, schedule_config={"trigger": "manual", "cron_expr": None, "timezone": "UTC"}, runner_options=runner_options, ) async def _run(db, runner_options: dict) -> dict[str, str | None]: await _seed_tree(db) tgt = _target(runner_options) db.add(tgt) await db.flush() runner = _CapturingDeptRunner() await IdentitySyncService(db)._huilianyi_dept_phase_body( target=tgt, runner=runner, summary=RunSummary(), ) return runner.calls @pytest.mark.asyncio async def test_root_bu_reparented_under_pingcap_root(db_session): calls = await _run(db_session, runner_options={}) # The Helix-root BU nests under the vendor PingCAP root (default RootOrg). assert calls[_ROOT_BU] == HUILIANYI_DEFAULT_ROOT_DEPT_CODE == "RootOrg" # A real child keeps its actual in-scope parent, untouched. assert calls[_CHILD] == _ROOT_BU @pytest.mark.asyncio async def test_root_code_overridable_via_runner_options(db_session): calls = await _run( db_session, runner_options={"huilianyi_root_dept_code": "ORG-TOP"}, ) assert calls[_ROOT_BU] == "ORG-TOP" assert calls[_CHILD] == _ROOT_BU @pytest.mark.asyncio async def test_empty_root_code_disables_reparenting(db_session): # Explicit empty string opts out — root BUs stay vendor-top-level (None). calls = await _run( db_session, runner_options={"huilianyi_root_dept_code": ""}, ) assert calls[_ROOT_BU] is None assert calls[_CHILD] == _ROOT_BU @pytest.mark.asyncio async def test_restrict_dept_codes_processes_only_that_chain(db_session): """Single-person ensure path: ``restrict_dept_codes`` narrows Phase A to exactly the given codes (a new hire's dept chain), bypassing the full in-scope computation. Here the full scope would be {ROOT_BU, CHILD} (the seeded member pulls in its ancestor), but restricting to {CHILD} pushes ONLY that dept — so a per-hire ensure never sweeps the whole tree. """ await _seed_tree(db_session) tgt = _target({}) db_session.add(tgt) await db_session.flush() runner = _CapturingDeptRunner() await IdentitySyncService(db_session)._huilianyi_dept_phase_body( target=tgt, runner=runner, summary=RunSummary(), restrict_dept_codes={_CHILD}, ) assert set(runner.calls) == {_CHILD}