from datetime import date import pytest from sqlalchemy import select from app.identity.person_models import ( IdentityExternalIdentity, IdentityOrgMembership, IdentityOrgNode, IdentityPerson, ) class _FakeSuccessFactorsClient: async def list_active_companies(self) -> list[dict]: return [ { "externalCode": "LE90016", "name": "PingCAP_China", "country": "CHN", } ] async def list_active_departments(self) -> list[dict]: return [ { "externalCode": "DP00001", "name": "APAC Business Group", "parent": None, "cust_orglevel": "1", "cust_L0": "PingCAP", "cust_L1": "DP00001", "cust_L2": None, "cust_L3": None, "cust_L4": None, "cust_L5": None, }, { "externalCode": "DP00022", "name": "Innovation Platform", "parent": "DP00001", "cust_orglevel": "2", "cust_L0": "PingCAP", "cust_L1": "DP00001", "cust_L2": "DP00022", "cust_L3": None, "cust_L4": None, "cust_L5": None, }, ] async def list_active_locations(self) -> list[dict]: return [ { "externalCode": "CN0198", "name": "北京市", } ] async def list_current_jobs(self, limit: int = 10) -> list[dict]: jobs = [ { "userId": "M0001", "company": "LE90016", "department": "DP00001", "location": "CN0198", "managerId": None, }, { "userId": "P0016", "company": "LE90016", "department": "DP00022", "location": "CN0198", "managerId": "M0001", }, ] return jobs[:limit] async def get_users(self, user_ids: list[str]) -> list[dict]: users = { "M0001": { "userId": "M0001", "displayName": "Manager User", "email": "manager@example.com", "empId": "EMP-MGR-001", "status": "active", "hireDate": "/Date(1672876800000+0000)/", "lastModifiedDateTime": "/Date(1738627200000+0000)/", }, "P0016": { "userId": "P0016", "displayName": "Zhai Yulong", "email": "zhai@example.com", "empId": "EMP-0016", "status": "active", "hireDate": "/Date(1710720000000+0000)/", "lastModifiedDateTime": "/Date(1738627200000+0000)/", }, } return [users[user_id] for user_id in user_ids if user_id in users] async def list_job_relationships( self, relationship_types: list[str], ) -> list[dict]: # Default: no work relationships (HRBP / Budget Owner unset). return [] class _FakeClientWithRoles(_FakeSuccessFactorsClient): """P0016's HRBP (2448) + Budget Owner / 调薪负责人 (2447) both point at the manager M0001 — the member-shared per-BU pattern.""" async def list_job_relationships( self, relationship_types: list[str], ) -> list[dict]: return [ {"userId": "P0016", "relationshipType": "2448", "relUserId": "M0001"}, {"userId": "P0016", "relationshipType": "2447", "relUserId": "M0001"}, ] class _FakeSuccessFactorsClientDeptDeactivated(_FakeSuccessFactorsClient): """Second-run tenant state: DP00022 was deactivated in SF, so ``list_active_departments`` (``status eq 'A'``) no longer returns it. Everyone reports under the surviving DP00001.""" async def list_active_departments(self) -> list[dict]: return [ { "externalCode": "DP00001", "name": "APAC Business Group", "parent": None, "cust_orglevel": "1", "cust_L0": "PingCAP", "cust_L1": "DP00001", "cust_L2": None, "cust_L3": None, "cust_L4": None, "cust_L5": None, } ] async def list_current_jobs(self, limit: int = 10) -> list[dict]: jobs = [ { "userId": "M0001", "company": "LE90016", "department": "DP00001", "location": "CN0198", "managerId": None, }, { "userId": "P0016", "company": "LE90016", "department": "DP00001", "location": "CN0198", "managerId": "M0001", }, ] return jobs[:limit] class _FakeSuccessFactorsClientNoDepartment(_FakeSuccessFactorsClient): async def list_current_jobs(self, limit: int = 10) -> list[dict]: return [ { "userId": "P0016", "company": "LE90016", "department": None, "location": "CN0198", "managerId": None, } ] async def get_users(self, user_ids: list[str]) -> list[dict]: return [ { "userId": "P0016", "displayName": "Zhai Yulong", "email": "zhai@example.com", "empId": "EMP-0016", "status": "active", "hireDate": "/Date(1710720000000+0000)/", "lastModifiedDateTime": "/Date(1738627200000+0000)/", } ] @pytest.mark.asyncio async def test_successfactors_sync_imports_all_current_jobs_when_limit_is_none(db_session): from app.identity.successfactors_sync import SuccessFactorsSyncService service = SuccessFactorsSyncService(db_session, sf_client=_FakeSuccessFactorsClient()) summary = await service.sync_users(limit=None) await db_session.commit() assert summary.fetched_jobs == 2 assert summary.fetched_users == 2 assert summary.upserted_org_nodes == 2 result = await db_session.execute( select(IdentityPerson).where(IdentityPerson.source_system == "successfactors") ) assert len(result.scalars().all()) == 2 org_nodes_result = await db_session.execute( select(IdentityOrgNode).where(IdentityOrgNode.source_system == "successfactors") ) assert len(org_nodes_result.scalars().all()) == 2 @pytest.mark.asyncio async def test_successfactors_sync_populates_hris_role_relationships(db_session): """SF EmpJobRelationships (HRBP 2448 / Budget Owner 2447) land on the person's hris_hrbp_person_id / hris_budget_owner_person_id columns.""" from app.identity.successfactors_sync import SuccessFactorsSyncService service = SuccessFactorsSyncService(db_session, sf_client=_FakeClientWithRoles()) await service.sync_users(limit=None) await db_session.commit() by_no = { p.hris_person_id: p for p in ( await db_session.execute( select(IdentityPerson).where( IdentityPerson.source_system == "successfactors" ) ) ).scalars().all() } manager = by_no["M0001"] person = by_no["P0016"] assert person.hris_hrbp_person_id == manager.id assert person.hris_budget_owner_person_id == manager.id # Manager has no declared relationships → both stay None. assert manager.hris_hrbp_person_id is None assert manager.hris_budget_owner_person_id is None @pytest.mark.asyncio async def test_successfactors_sync_role_pull_failure_does_not_break_sync(db_session): """A failing EmpJobRelationships pull must NOT abort the roster sync — people still land, roles just stay unset.""" from app.identity.successfactors_sync import SuccessFactorsSyncService class _Boom(_FakeSuccessFactorsClient): async def list_job_relationships(self, relationship_types): raise RuntimeError("EmpJobRelationships not provisioned") service = SuccessFactorsSyncService(db_session, sf_client=_Boom()) summary = await service.sync_users(limit=None) await db_session.commit() assert summary.fetched_jobs == 2 # roster sync completed despite the failure person = ( await db_session.execute( select(IdentityPerson).where(IdentityPerson.hris_person_id == "P0016") ) ).scalar_one() assert person.hris_hrbp_person_id is None @pytest.mark.asyncio async def test_successfactors_sync_imports_people_memberships_and_manager(db_session): from app.identity.successfactors_sync import SuccessFactorsSyncService service = SuccessFactorsSyncService(db_session, sf_client=_FakeSuccessFactorsClient()) summary = await service.sync_users(limit=10) await db_session.commit() assert summary.fetched_jobs == 2 assert summary.upserted_org_nodes == 2 assert summary.upserted_people == 2 assert summary.upserted_memberships == 2 result = await db_session.execute( select(IdentityPerson).where(IdentityPerson.hris_person_id == "P0016") ) person = result.scalar_one() assert person.source_system == "successfactors" assert person.work_email == "zhai@example.com" assert person.display_name == "Zhai Yulong" assert person.employee_no == "EMP-0016" assert person.company_code == "LE90016" assert person.company_name == "PingCAP_China" assert person.work_location_code == "CN0198" assert person.work_location_name == "北京市" assert person.is_china_employee is True assert person.hire_date == date(2024, 3, 18) assert person.manager_person_id is not None result = await db_session.execute( select(IdentityPerson).where(IdentityPerson.id == person.manager_person_id) ) manager = result.scalar_one() assert manager.hris_person_id == "M0001" result = await db_session.execute( select(IdentityOrgMembership).where( IdentityOrgMembership.person_id == person.id, IdentityOrgMembership.is_primary.is_(True), ) ) membership = result.scalar_one() assert membership.org_node_source_id == "DP00022" assert membership.org_node_name == "Innovation Platform" assert membership.parent_org_node_source_id == "DP00001" assert membership.org_path == "PingCAP / APAC Business Group / Innovation Platform" result = await db_session.execute( select(IdentityOrgNode).where(IdentityOrgNode.org_node_source_id == "DP00022") ) org_node = result.scalar_one() assert org_node.org_node_name == "Innovation Platform" assert org_node.parent_org_node_source_id == "DP00001" assert org_node.org_level == 2 assert org_node.org_path == "PingCAP / APAC Business Group / Innovation Platform" result = await db_session.execute( select(IdentityExternalIdentity).where( IdentityExternalIdentity.person_id == person.id, IdentityExternalIdentity.provider == "successfactors", ) ) external_identity = result.scalar_one() assert external_identity.provider_subject == "P0016" assert external_identity.provider_email == "zhai@example.com" @pytest.mark.asyncio async def test_successfactors_sync_creates_external_identity_without_department(db_session): from app.identity.successfactors_sync import SuccessFactorsSyncService service = SuccessFactorsSyncService(db_session, sf_client=_FakeSuccessFactorsClientNoDepartment()) summary = await service.sync_users(limit=10) await db_session.commit() assert summary.fetched_jobs == 1 assert summary.upserted_org_nodes == 2 assert summary.upserted_people == 1 assert summary.upserted_memberships == 0 result = await db_session.execute( select(IdentityPerson).where(IdentityPerson.hris_person_id == "P0016") ) person = result.scalar_one() result = await db_session.execute( select(IdentityExternalIdentity).where( IdentityExternalIdentity.person_id == person.id, IdentityExternalIdentity.provider == "successfactors", ) ) external_identity = result.scalar_one_or_none() assert external_identity is not None assert external_identity.provider_subject == "P0016" @pytest.mark.asyncio async def test_get_departments_includes_empty_org_nodes_after_successfactors_sync(db_session): from app.auth.service import AuthService from app.identity.successfactors_sync import SuccessFactorsSyncService service = SuccessFactorsSyncService(db_session, sf_client=_FakeSuccessFactorsClientNoDepartment()) await service.sync_users(limit=None) await db_session.commit() departments = await AuthService(db_session).get_departments() assert departments == [ {"department": "APAC Business Group", "count": 0}, {"department": "Innovation Platform", "count": 0}, ] @pytest.mark.asyncio async def test_successfactors_sync_deactivates_org_nodes_dropped_from_active_set(db_session): """A department deactivated in SF drops out of the active-only pull; the next sync must flip its ``identity_org_nodes`` row to ``node_status='inactive'`` instead of leaving it stale-active forever.""" from app.identity.successfactors_sync import SuccessFactorsSyncService # First run: both departments active. await SuccessFactorsSyncService( db_session, sf_client=_FakeSuccessFactorsClient() ).sync_users(limit=None) await db_session.commit() nodes = { n.org_node_source_id: n for n in ( await db_session.execute( select(IdentityOrgNode).where( IdentityOrgNode.source_system == "successfactors" ) ) ).scalars().all() } assert nodes["DP00001"].node_status == "active" assert nodes["DP00022"].node_status == "active" # Second run: DP00022 deactivated upstream → absent from the pull. await SuccessFactorsSyncService( db_session, sf_client=_FakeSuccessFactorsClientDeptDeactivated() ).sync_users(limit=None) await db_session.commit() nodes = { n.org_node_source_id: n for n in ( await db_session.execute( select(IdentityOrgNode).where( IdentityOrgNode.source_system == "successfactors" ) ) ).scalars().all() } # Surviving dept stays active; dropped dept is now inactive (row kept). assert nodes["DP00001"].node_status == "active" assert nodes["DP00022"].node_status == "inactive" assert nodes["DP00022"].deleted_at is None @pytest.mark.asyncio async def test_deactivate_absent_org_nodes_empty_set_is_noop(db_session): """An empty active set (transient upstream failure) must never tombstone the whole tree.""" from app.identity.identity_reconcile import deactivate_absent_org_nodes await _seed_two_active_departments(db_session) changed = await deactivate_absent_org_nodes(db_session, []) await db_session.commit() assert changed == 0 still_active = ( await db_session.execute( select(IdentityOrgNode).where( IdentityOrgNode.source_system == "successfactors", IdentityOrgNode.node_status == "active", ) ) ).scalars().all() assert len(still_active) == 2 @pytest.mark.asyncio async def test_reactivated_department_flips_back_to_active(db_session): """A department that comes back to ``status='A'`` in SF reappears in the active pull and the upsert restores ``node_status='active'``.""" from app.identity.successfactors_sync import SuccessFactorsSyncService # Deactivate DP00022. await SuccessFactorsSyncService( db_session, sf_client=_FakeSuccessFactorsClient() ).sync_users(limit=None) await db_session.commit() await SuccessFactorsSyncService( db_session, sf_client=_FakeSuccessFactorsClientDeptDeactivated() ).sync_users(limit=None) await db_session.commit() # DP00022 re-activated upstream → full active set again. await SuccessFactorsSyncService( db_session, sf_client=_FakeSuccessFactorsClient() ).sync_users(limit=None) await db_session.commit() node = ( await db_session.execute( select(IdentityOrgNode).where( IdentityOrgNode.org_node_source_id == "DP00022" ) ) ).scalar_one() assert node.node_status == "active" async def _seed_two_active_departments(db_session): from app.identity.successfactors_sync import SuccessFactorsSyncService await SuccessFactorsSyncService( db_session, sf_client=_FakeSuccessFactorsClient() ).sync_users(limit=None) await db_session.commit()