"""In-place recode: Helix SF dept codes → existing 汇联易 custDeptNumbers. The Authing/北森-era 汇联易 tree and Helix's SF tree share no code, so pushing with SF codes creates a PARALLEL tree (and same-name siblings under RootOrg collide — errorCode 120101). The cutover maps SF code → existing vendor code and UPDATEs the legacy dept in place. These tests cover the runner-level remap (dept + user) and the path-disambiguated map generator. """ from __future__ import annotations import uuid import pytest from app.domains.identity_sync.runners.huilianyi import ( HuilianyiDepartmentRunner, HuilianyiRunner, ) _MAP = {"2730378": "20220401-17", "3963958": "CEG2025062001"} class _Calls(list): """Records (method, path, json_body) and returns queued responses. Each queued response is either a dict (→ ``(201, dict, "")``) or a ``(status, dict)`` tuple for non-2xx cases (e.g. a 404 user lookup). """ def __init__(self, responses): super().__init__() self._responses = list(responses) async def __call__(self, method, path, *, params=None, json_body=None, **_kw): self.append((method, path, json_body)) r = self._responses.pop(0) if self._responses else {"errorCode": "0000"} if isinstance(r, tuple): status, body = r return status, body, "" return 201, r, "" class _Client: def __init__(self, calls): self._request = calls async def request(self, *a, **k): return await self._request(*a, **k) async def aclose(self): return None # ── dept runner remap ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_dept_upsert_remaps_code_and_parent_to_vendor(): calls = _Calls([{"errorCode": "0000", "oid": "OID1"}]) runner = HuilianyiDepartmentRunner(client=_Client(calls), dept_code_map=_MAP) out = await runner.upsert_dept( dept_code="3963958", dept_name="IT Services & Security", parent_dept_code="2730378", leader_email=None, dept_order=None, prior_external_id=None, ) assert out.status == "success" body = calls[-1][2] # SF codes translated to the existing vendor codes (UPDATE in place). assert body["custDeptNumber"] == "CEG2025062001" assert body["parentCustDeptNumber"] == "20220401-17" @pytest.mark.asyncio async def test_dept_unmapped_code_passes_through(): """A new dept absent from the map keeps its SF code (created fresh).""" calls = _Calls([{"errorCode": "0000", "oid": "OID2"}]) runner = HuilianyiDepartmentRunner(client=_Client(calls), dept_code_map=_MAP) await runner.upsert_dept( dept_code="9999999", dept_name="Brand New Dept", parent_dept_code="2730378", leader_email=None, dept_order=None, prior_external_id=None, ) body = calls[-1][2] assert body["custDeptNumber"] == "9999999" # unmapped → passthrough assert body["parentCustDeptNumber"] == "20220401-17" # parent still remaps @pytest.mark.asyncio async def test_dept_empty_map_is_passthrough(): calls = _Calls([{"errorCode": "0000"}]) runner = HuilianyiDepartmentRunner(client=_Client(calls), dept_code_map={}) await runner.upsert_dept( dept_code="2730378", dept_name="Business Operations", parent_dept_code="RootOrg", leader_email=None, dept_order=None, prior_external_id=None, ) body = calls[-1][2] assert body["custDeptNumber"] == "2730378" assert body["parentCustDeptNumber"] == "RootOrg" @pytest.mark.asyncio async def test_dept_soft_delete_remaps_url_code(): calls = _Calls([{"errorCode": "0000"}]) runner = HuilianyiDepartmentRunner(client=_Client(calls), dept_code_map=_MAP) await runner.soft_delete(dept_code="3963958", prior_external_id="x") method, path, _ = calls[-1] assert method == "DELETE" assert path.endswith("/delete/v2/CEG2025062001") # vendor code in URL @pytest.mark.asyncio async def test_dept_put_vanished_120103_falls_back_to_post(): """Regression (2026-06-16 cutover): a dept PUT that returns 120103 (部门不存在) must drop to POST-recreate. ``DEPT_NOT_FOUND`` was mis-set to 120110 (the USER-create code), so the vendor's actual dept-not-found code (120103) was treated as a hard failure and the recreate fallback never fired.""" calls = _Calls([ (200, {"errorCode": "120103", "message": "部门不存在"}), # PUT → gone {"errorCode": "0000", "oid": "NEWOID"}, # POST → created ]) runner = HuilianyiDepartmentRunner(client=_Client(calls), dept_code_map={}) out = await runner.upsert_dept( dept_code="3874881", dept_name="Growth Dev", parent_dept_code=None, leader_email=None, dept_order=None, prior_external_id="STALE_OID", # truthy → PUT-first path ) assert out.status == "success" assert [c[0] for c in calls] == ["PUT", "POST"] # PUT vanished → recreated # ── user runner remap ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_user_upsert_remaps_custdeptnumber(): # GET lookup → 404 (absent) routes to the real _create POST, whose body # we capture to assert the dept code was remapped before the wire. calls = _Calls([(404, {"errorCode": "120207"}), {"errorCode": "0000"}]) runner = HuilianyiRunner(client=_Client(calls), dept_code_map=_MAP) out = await runner.upsert( person_email="alice@pingcap.com", payload={ "employeeID": "P0989", "fullName": "冯帅", "email": "alice@pingcap.com", "custDeptNumber": "3963958", "companyCode": "001", }, prior_external_id=None, ) assert out.status == "success" post = [c for c in calls if c[0] == "POST"][-1] assert post[2]["custDeptNumber"] == "CEG2025062001" # remapped on the wire # ── path-disambiguated map generator ───────────────────────────────── @pytest.mark.asyncio async def test_generator_path_disambiguates_duplicate_names(db_session, monkeypatch): """Two depts both named 'Sales' under different BUs must map to different vendor codes via PATH, and Helix's own SF-coded parallels are excluded.""" from app.domains.identity_sync.models import IdentitySyncTarget from app.domains.identity_sync.service import IdentitySyncService from app.identity.person_models import IdentityOrgNode db_session.add_all([ IdentityOrgNode( id=uuid.uuid4(), org_node_source_id="SF-ENG-SALES", org_node_name="Sales", node_status="active", org_path="PingCAP / Engineering Group / Sales"), IdentityOrgNode( id=uuid.uuid4(), org_node_source_id="SF-APAC-SALES", org_node_name="Sales", node_status="active", org_path="PingCAP / APAC Business Group / Sales"), IdentityOrgNode( id=uuid.uuid4(), org_node_source_id="SF-NEW", org_node_name="Brand New", node_status="active", org_path="PingCAP / Engineering Group / Brand New"), ]) await db_session.flush() tgt = IdentitySyncTarget( id=uuid.uuid4(), name="hly", slug="hly-gen", kind="huilianyi", enabled=False, on_offboard="deactivate", mapping_config={"rules": []}, filter_config={}, schedule_config={"trigger": "manual"}, runner_options={}, ) db_session.add(tgt) await db_session.flush() vendor = [ {"custDeptNumber": "ENG-S", "departmentName": "Sales", "status": 101, "departmentPath": "PingCAP|Engineering Group|Sales"}, {"custDeptNumber": "APAC-S", "departmentName": "Sales", "status": 101, "departmentPath": "PingCAP|APAC Business Group|Sales"}, # Helix's own SF-coded parallel — must be EXCLUDED from the index. {"custDeptNumber": "SF-ENG-SALES", "departmentName": "Sales", "status": 101, "departmentPath": "PingCAP|Engineering Group|Sales"}, # disabled vendor dept — ignored. {"custDeptNumber": "OLD", "departmentName": "Brand New", "status": 102, "departmentPath": "PingCAP|Engineering Group|Brand New"}, ] svc = IdentitySyncService(db_session) monkeypatch.setattr(svc, "_build_huilianyi_client", lambda target: _async(_Client(_Calls([])))) async def _fetch(_client): return vendor monkeypatch.setattr( "app.domains.identity_sync.runners.huilianyi.fetch_all_departments", _fetch, ) result = await svc.generate_huilianyi_dept_code_map(target=tgt) # Path disambiguation: same name 'Sales', different BU → different code. assert result["map"]["SF-ENG-SALES"] == "ENG-S" assert result["map"]["SF-APAC-SALES"] == "APAC-S" # 'Brand New' only exists disabled on the vendor → unmatched (create fresh). assert any(u["sf_code"] == "SF-NEW" for u in result["unmatched"]) assert result["summary"]["ambiguous"] == 0 def _async(val): async def _coro(): return val return _coro()