"""Authing-parity gap-fill — mobile / join_time / entryTime / AWS AI. The Authing decommission audit (live console read, 2026-06-11) showed the legacy downstream pushes carried fields Helix's presets didn't: 飞书#0 pushed 手机号 + 入职日期; the live 汇联易 roster stores real ``mobile`` + 入职时间. These tests pin the gap-fill: * ``{mobile_phone}`` is a first-class mapping source field * feishu preset maps mobile + join_time; the runner converts join_time to unix seconds (Feishu wire = int) * huilianyi preset maps mobile + 入职时间 as ``entryTime`` — the vendor's WRITE field name. It was written as ``entryDate`` (the READ/roster field) until 2026-08-27, which the vendor silently ignores, so 入职时间 never landed on any Helix-pushed employee. * the AWS AI users-only preset exists with push_groups=false """ from __future__ import annotations import pytest from app.domains.identity_sync.mapping import ( SUPPORTED_SOURCE_FIELDS, MappingConfig, render_payload, ) from app.domains.identity_sync.presets import ( FEISHU_CONTACT_USER_MAPPING_RULES, HUILIANYI_USER_MAPPING_RULES, list_presets, ) def test_mobile_phone_is_supported_source_field(): assert "mobile_phone" in SUPPORTED_SOURCE_FIELDS cfg = MappingConfig.from_dict({"rules": [ {"source_template": "{mobile_phone}", "target_field": "mobile"}, ]}) payload = render_payload(cfg, {"mobile_phone": "13800000000"}) assert payload == {"mobile": "13800000000"} def test_presets_carry_parity_fields(): feishu_targets = { r["target_field"] for r in FEISHU_CONTACT_USER_MAPPING_RULES } assert {"mobile", "join_time"} <= feishu_targets hly_targets = { r["target_field"] for r in HUILIANYI_USER_MAPPING_RULES } # ``entryTime`` — the WRITE field. NOT ``entryDate`` (that is what the # 增量查询员工 READ endpoint echoes); the vendor ignores unknown keys, so # the read name silently no-op'd 入职时间 on every push until 2026-08-27. assert {"mobile", "entryTime"} <= hly_targets # 主岗直属领导 — vendor's own typo'd spelling, verified in the live # opendocs field tables. ``directManagerEmployeeId`` (correct spelling) # is a per-position field inside userJobsDtos, ignored at top level. assert "directManagerEmpoyeeId" in hly_targets def test_aws_ai_users_only_preset(): presets = {p.key: p for p in list_presets()} p = presets["aws_identity_center_users_only"] assert p.kind == "scim" assert p.runner_options["push_groups"] is False # Same field mapping as the full IdC preset. full = presets["aws_identity_center"] assert p.mapping_rules == full.mapping_rules assert full.runner_options["push_groups"] is True def test_feishu_join_time_converted_to_unix_seconds(): from app.domains.identity_sync.runners.feishu_contact import ( FeishuContactRunner, ) runner = FeishuContactRunner( client=object(), helix_dept_to_external={}, office_name_to_external={}, ) body = runner._build_user_body( person_email="a@pingcap.com", payload={ "email": "a@pingcap.com", "name": "Alice", "join_time": "2023-04-01", "mobile": "13800000000", }, leader_user_id=None, department_ids_feishu=None, ) assert body["join_time"] == 1680307200 # 2023-04-01T00:00:00Z assert isinstance(body["join_time"], int) # mobile gets the country calling code (Feishu wants +); # no country in the payload → default +86, matching the legacy # Authing 飞书 task's ``phoneCountryCode || '+86'`` fallback. assert body["mobile"] == "+8613800000000" # Unparseable join_time is dropped, never raises. body = runner._build_user_body( person_email="a@pingcap.com", payload={"email": "a@pingcap.com", "join_time": "not-a-date"}, leader_user_id=None, department_ids_feishu=None, ) assert "join_time" not in body def test_format_mobile_e164_helper(): """Authing-parity gap-fill: Feishu wants +; the legacy Authing 飞书 task did ``(phoneCountryCode||'+86')+phone``. Helix resolves the code from the HRIS country (ISO-3).""" from app.domains.identity_sync.runners.feishu_contact import ( _format_mobile_e164, ) # ISO-2 country → calling code (country is value-mapped to ISO-2 upstream) assert _format_mobile_e164("13800138000", "CN") == "+8613800138000" assert _format_mobile_e164("09012345678", "JP") == "+819012345678" # trunk 0 stripped assert _format_mobile_e164("(415) 555-1234", "US") == "+14155551234" # punctuation stripped # EMPTY/unknown country → default +86 (CN-majority tenant, Authing parity) assert _format_mobile_e164("13800138000", None) == "+8613800138000" assert _format_mobile_e164("13800138000", "") == "+8613800138000" # country SET but NOT in the dialing-code table → drop (don't guess +86 # for a known-foreign person; a wrong calling code is worse than absent). # ``ZZ`` is a permanently-unassigned ISO-3166 code, so this branch stays # reachable now that the dialing table covers every real country. assert _format_mobile_e164("11987654321", "ZZ") == "" # A real foreign country now IN the comprehensive table formats (Brazil # +55) — it used to drop back when the table was footprint-scoped. assert _format_mobile_e164("11987654321", "BR") == "+5511987654321" # idempotent — already-+ / 00-international are not double-prefixed # (the +/00 shapes carry their own code and skip country resolution, # so an unmapped country does NOT drop them) assert _format_mobile_e164("+5511987654321", "BR") == "+5511987654321" assert _format_mobile_e164("+8613800138000", "CHN") == "+8613800138000" assert _format_mobile_e164("00819012345678", None) == "+819012345678" # empty / digit-less / all-zeros → drop (caller omits the slot); the # all-zeros guard also stops "000" becoming a bogus "+0". assert _format_mobile_e164("", "CHN") == "" assert _format_mobile_e164(" ", "CHN") == "" assert _format_mobile_e164("000", "CHN") == "" assert _format_mobile_e164("00", None) == "" # Regression (raw-country tiebreak): the ``country`` value-map renders # EMPTY both for a genuinely-unset country AND for a set-but-unmapped one # (miss, no passthrough). The synthetic ``mobile_country_iso3`` carries # the raw HRIS country so those two cases don't collapse: # * ISO-2 empty + raw country SET (unmapped) → DROP (never guess +86) assert _format_mobile_e164("11987654321", "", raw_country_iso3="BRA") == "" assert _format_mobile_e164("11987654321", None, raw_country_iso3="EGY") == "" # * ISO-2 empty + raw country also empty → +86 default (CN majority) assert _format_mobile_e164("13800138000", "", raw_country_iso3="") == "+8613800138000" assert _format_mobile_e164("13800138000", None, raw_country_iso3=None) == "+8613800138000" # * ISO-2 present (normal mapped case) → raw is irrelevant, use ISO-2 assert _format_mobile_e164("13800138000", "CN", raw_country_iso3="CHN") == "+8613800138000" # ── phone_country_iso2 PRECEDENCE (international assignment) ────────── # zhaohui's exact case: home country CN, host-country phone (+65 SG). # The phone's own country wins over the home country → +65, not +86. assert _format_mobile_e164( "81738066", "CN", raw_country_iso3="CHN", phone_country_iso2="SG", ) == "+6581738066" # Phone country wins even when home country is empty/unmapped. assert _format_mobile_e164( "81738066", "", raw_country_iso3="CHN", phone_country_iso2="SG", ) == "+6581738066" # Phone country SET but unmapped → DROP (never fall back to home +86 for a # known-foreign phone — same stance as the home-country path). assert _format_mobile_e164( "81738066", "CN", phone_country_iso2="ZZ", ) == "" # Phone country ABSENT → falls back to the home-country derivation. assert _format_mobile_e164( "13800138000", "CN", phone_country_iso2=None, ) == "+8613800138000" assert _format_mobile_e164( "13800138000", "CN", phone_country_iso2="", ) == "+8613800138000" # ── Blind +86 default must not emit an INVALID Chinese number ──────── # Regression (2026-07-13, P10011 Eddie Jiang): country genuinely unknown # → we guess +86, but a foreign national number ("0938769558", a TW/MY # joiner whose country wasn't synced) becomes "+86938769558" — a number # Feishu REJECTS (99992402) and, riding in the same create body, fails the # WHOLE account create (no Feishu account minted at all). Under the blind # +86 default the number must look like a CN mobile (11 digits, leading 1) # or the slot is dropped so the account is still created (phone # correctable later). Only the blind default is guarded. assert _format_mobile_e164("0938769558", None) == "" # foreign, no country → drop assert _format_mobile_e164("938769558", "", raw_country_iso3="") == "" # 9-digit, not CN assert _format_mobile_e164("138001380001", None) == "" # 12-digit, not CN mobile assert _format_mobile_e164("23800138000", None) == "" # 11-digit but not leading 1 # A real CN mobile under the default still formats (guard passes). assert _format_mobile_e164("13800138000", None) == "+8613800138000" # Explicit phone-country is TRUSTED even when the number isn't CN-shaped: # a TW phone tagged TW keeps +886 (the guard fires only on the blind +86). assert _format_mobile_e164( "0938769558", None, phone_country_iso2="TW", ) == "+886938769558" def test_country_code_picklist_resolution(): """SF ``PerPhone.countryCode`` is a picklist option id; the client resolves it to ISO-2 via the ``CountryCode`` picklist's ``externalCode`` (option 1734 → SG, verified live 2026-07-07).""" import asyncio from app.identity.successfactors_client import SuccessFactorsClient class _FakeAuth: async def apply(self, request): # pragma: no cover - unused return None cl = SuccessFactorsClient(base_url="https://x.example/odata/v2", auth=_FakeAuth()) async def _fake_list(entity, *, select_fields=None, filter_clause=None): assert entity == "PickListValueV2" assert filter_clause == "PickListV2_id eq 'CountryCode'" return [ {"optionId": 1734, "externalCode": "SG"}, {"optionId": 1, "externalCode": "cn"}, # lower → normalised upper {"optionId": 99, "externalCode": ""}, # empty → skipped {"optionId": None, "externalCode": "US"}, # no id → skipped ] cl._list_entities = _fake_list # type: ignore[assignment] out = asyncio.get_event_loop().run_until_complete(cl.list_country_code_picklist()) assert out == {"1734": "SG", "1": "CN"} def test_feishu_runner_formats_mobile_with_country(): """The body builder prepends the country calling code resolved from the rendered ``country`` field, and drops the slot for a blank number (Feishu keeps its existing value — never NULL-out).""" from app.domains.identity_sync.runners.feishu_contact import ( FeishuContactRunner, ) runner = FeishuContactRunner( client=object(), helix_dept_to_external={}, office_name_to_external={}, ) body = runner._build_user_body( person_email="a@pingcap.com", payload={ "email": "a@pingcap.com", "name": "Akira", # runner receives the value-mapped ISO-2 country (JPN → JP) "country": "JP", "mobile": "09012345678", }, leader_user_id=None, department_ids_feishu=None, ) assert body["mobile"] == "+819012345678" # Blank mobile → slot dropped entirely. body = runner._build_user_body( person_email="a@pingcap.com", payload={"email": "a@pingcap.com", "country": "JP", "mobile": ""}, leader_user_id=None, department_ids_feishu=None, ) assert "mobile" not in body def test_feishu_preset_maps_gender_employee_type_office_location(): """migration 344 / PR2: the feishu preset projects SF picklist codes to Feishu enums/labels, and does NOT map job_function (no SF source).""" cfg = MappingConfig.from_dict( {"rules": list(FEISHU_CONTACT_USER_MAPPING_RULES)} ) payload = render_payload(cfg, { "work_email": "Shuai.Feng@pingcap.com", "full_name": "Shuai Feng", "first_name": "Shuai", "employee_id": "P0989", "gender": "M", "employee_type_code": "1070", "office_code": "2659", "name_pinyin": "fengshuai", "company_name": "01_上海楷浦", }) # Enums rendered as the value-mapped string (runner int-coerces later). assert payload["gender"] == "1" assert payload["employee_type"] == "1" # Office code → Feishu Location custom-attr label. assert payload["custom_attrs"]["location"] == "SH Office" # Contact email mirrors the (lowercased) work email. assert payload["custom_attrs"]["contact_email"] == "shuai.feng@pingcap.com" def test_feishu_employee_type_code_mapping_hr_confirmed(): """SF EmpJob.customString25 code → Feishu employee_type enum, mapping CONFIRMED WITH HR 2026-07-02; 1074/1075 corrected 2026-07-15. Feishu has only the 5 built-in types (1正式/2实习/3外包/4劳务/5顾问). SF swapped 派遣人员/外包人员 between codes 1074/1075 — this pins the correction.""" cfg = MappingConfig.from_dict( {"rules": list(FEISHU_CONTACT_USER_MAPPING_RULES)} ) def et(code: str) -> str: return render_payload(cfg, {"employee_type_code": code}).get("employee_type") assert et("1070") == "1" # 正式 → 正式 assert et("1071") == "4" # EOR → 劳务 assert et("1072") == "2" # 实习生 → 实习 assert et("1073") == "5" # 劳务/顾问 → 顾问 assert et("1074") == "3" # 外包人员 → 外包 assert et("1075") == "4" # 派遣人员 → 劳务 # Unknown code → dropped (Feishu keeps existing value). assert "employee_type" not in render_payload(cfg, {"employee_type_code": "9999"}) def test_feishu_preset_country_iso2_and_city_mapped(): """Region(country): Feishu stores ISO-2 (CN→'Chinese Mainland'); the ISO-3 HRIS code is value-mapped to ISO-2, and a miss drops the field (preserve Feishu Region). city IS pushed from HRIS (2026-07-01).""" cfg = MappingConfig.from_dict( {"rules": list(FEISHU_CONTACT_USER_MAPPING_RULES)} ) assert render_payload(cfg, {"country": "CHN"})["country"] == "CN" assert render_payload(cfg, {"country": "JPN"})["country"] == "JP" assert render_payload(cfg, {"country": "USA"})["country"] == "US" assert render_payload(cfg, {"country": "SGP"})["country"] == "SG" # unmapped / empty country → dropped (Feishu keeps its Region) assert "country" not in render_payload(cfg, {"country": "ZZZ"}) assert "country" not in render_payload(cfg, {"country": ""}) # city: the administrative 市 suffix is stripped so Feishu's city # dictionary renders it ("上海市" → "上海", parallel to the country # ISO-3→ISO-2 fix). Clean Chinese names pass through; romanised overseas # cities pass through UNCHANGED — Feishu stores + renders them (verified # live 2026-07-16), so dropping them (the old behaviour) blanked every # overseas employee's city. Empty stays dropped (write-when-present # preserves the vendor value). assert render_payload(cfg, {"city": "上海市"})["city"] == "上海" assert render_payload(cfg, {"city": "北京"})["city"] == "北京" assert render_payload(cfg, {"city": "Austin"})["city"] == "Austin" assert render_payload(cfg, {"city": "Tokyo"})["city"] == "Tokyo" assert "city" not in render_payload(cfg, {"city": ""}) # name = CJK part of display_name (Chinese, 中文-searchable); en_name = # the Latin part (PREFERRED English), both from SF displayName — NOT # the email-derived full_name. "孟凡生 Jason Meng" → name 孟凡生 / # en_name "Jason Meng" (fixes the "Fansheng Meng" regression that # erased the Chinese name and used the email romanisation). p_cn = render_payload(cfg, {"display_name": "孟凡生 Jason Meng", "full_name": "Fansheng Meng"}) assert p_cn["name"] == "孟凡生" assert p_cn["en_name"] == "Jason Meng" # Foreign staff (no CJK) → both are the English name. p_en = render_payload(cfg, {"display_name": "John Smith"}) assert p_en["name"] == "John Smith" assert p_en["en_name"] == "John Smith" # Pure-CJK display_name → en_name drops (Feishu keeps existing). p_pure = render_payload(cfg, {"display_name": "张三"}) assert p_pure["name"] == "张三" assert "en_name" not in p_pure def test_feishu_preset_pinyin_and_job_function_family(): """PinYin = CJK-only pinyin of display_name ('冯帅 Shuai Feng' → 'fengshuai'); Job Function / Job Family pushed from SF (2026-07-01).""" cfg = MappingConfig.from_dict( {"rules": list(FEISHU_CONTACT_USER_MAPPING_RULES)} ) p = render_payload(cfg, { "display_name": "冯帅 Shuai Feng", "job_function": "IT Service", "job_family": "IT Management", }) assert p["custom_attrs"]["pinyin"] == "fengshuai" # no English tail assert p["custom_attrs"]["job_function"] == "IT Service" assert p["custom_attrs"]["job_family"] == "IT Management" # foreign (no-CJK) display_name → pinyin drops (Feishu unchanged) p2 = render_payload(cfg, {"display_name": "Sally Hua"}) assert "pinyin" not in p2.get("custom_attrs", {}) def test_feishu_country_iso2_and_mobile_agree_end_to_end(): """Regression: the country value-map (ISO-3→ISO-2) and the runner's mobile calling-code table (ISO-2 keyed) MUST agree. The runner reads the rendered ``country`` (now 'CN'), so if the dialing table were still ISO-3 keyed, 'CN' would miss → mobile silently dropped for everyone.""" from app.domains.identity_sync.runners.feishu_contact import ( FeishuContactRunner, ) cfg = MappingConfig.from_dict( {"rules": list(FEISHU_CONTACT_USER_MAPPING_RULES)} ) payload = render_payload(cfg, { "work_email": "a@pingcap.com", "country": "CHN", "mobile_phone": "13800138000", }) assert payload["country"] == "CN" # value-mapped to ISO-2 runner = FeishuContactRunner( client=object(), helix_dept_to_external={}, office_name_to_external={}, ) body = runner._build_user_body( person_email="a@pingcap.com", payload=payload, leader_user_id=None, department_ids_feishu=None, ) assert body["country"] == "CN" assert body["mobile"] == "+8613800138000" # NOT dropped def test_feishu_preset_drops_unmapped_enum_and_office_codes(): """A value-map miss (unknown SF code) renders empty → field dropped → Feishu keeps its existing value (never NULL-out).""" cfg = MappingConfig.from_dict( {"rules": list(FEISHU_CONTACT_USER_MAPPING_RULES)} ) payload = render_payload(cfg, { "work_email": "x@pingcap.com", "gender": "X", # not in {M,F} "employee_type_code": "9999", # unknown code "office_code": "9999", # unknown office }) assert "gender" not in payload assert "employee_type" not in payload assert "location" not in payload.get("custom_attrs", {}) def test_feishu_preset_omits_no_source_and_feishu_managed_fields(): """HRIS→Helix→飞书: ``enterprise_email`` (Feishu-Mail-managed, sparse) and ``Beisen ID`` (北森 is the retired HRIS — SF has no source) are deliberately NOT mapped so Helix never regresses Feishu's values. NOTE (2026-07-01): GitHub ID, Job Function and Job Family are NO LONGER in this omit list — per the user decision to sync every field Feishu carries with HRIS as source of truth, they are now mapped from SF (github_id ← cust_Other.cust_id; job_function ← FOJobFunction; job_family ← cust_JobFamily), overwriting even where SF differs from Feishu's 北森-era value. See DECISIONS.md (2026-07-01).""" targets = {r["target_field"] for r in FEISHU_CONTACT_USER_MAPPING_RULES} custom_attr_keys = { t.split(".", 1)[1] for t in targets if t.startswith("custom_attrs.") } assert "enterprise_email" not in targets assert not ({"beisen_id", "last_updated"} & custom_attr_keys) def test_feishu_preset_maps_github_id(): """GitHub ID IS mapped (migration 369): SF cust_Other.cust_id → ``{github_id}`` source field → Feishu ``custom_attrs.github_id`` (resolves to the live "GitHub ID" schema by normalised i18n_name). A person with a handle renders it; an empty source drops the rule so Feishu's existing value is preserved (never NULL-out).""" assert "github_id" in SUPPORTED_SOURCE_FIELDS cfg = MappingConfig.from_dict( {"rules": list(FEISHU_CONTACT_USER_MAPPING_RULES)} ) payload = render_payload(cfg, { "work_email": "k@pingcap.com", "github_id": "KennethLeeJE8", }) assert payload["custom_attrs"]["github_id"] == "KennethLeeJE8" # Empty github_id → rule drops → key absent (Feishu keeps its value). payload2 = render_payload(cfg, {"work_email": "x@pingcap.com", "github_id": ""}) assert "github_id" not in payload2.get("custom_attrs", {}) def test_feishu_runner_int_coerces_gender_and_employee_type(): """migration 344 / PR2: Feishu wire wants integer enums; the body builder coerces gender/employee_type to int and drops empty values.""" from app.domains.identity_sync.runners.feishu_contact import ( FeishuContactRunner, ) runner = FeishuContactRunner( client=object(), helix_dept_to_external={}, office_name_to_external={}, ) body = runner._build_user_body( person_email="a@pingcap.com", payload={ "email": "a@pingcap.com", "name": "Alice", "gender": "2", "employee_type": "3", }, leader_user_id=None, department_ids_feishu=None, ) assert body["gender"] == 2 and isinstance(body["gender"], int) assert body["employee_type"] == 3 and isinstance(body["employee_type"], int) # Empty / unparseable enum values are dropped, never raise. body = runner._build_user_body( person_email="a@pingcap.com", payload={"email": "a@pingcap.com", "gender": "", "employee_type": "x"}, leader_user_id=None, department_ids_feishu=None, ) assert "gender" not in body assert "employee_type" not in body @pytest.mark.asyncio async def test_huilianyi_entry_time_normalised_on_upsert(): from tests.test_identity_sync.test_huilianyi_runner import ( _ScriptedCalls, _user_runner, ) runner, calls = _user_runner([ # lookup by 工号 → exists ("GET", "/api/open/user/detail/P00001", (200, {"employeeID": "P00001"}, "")), # update v2 → ok ("PUT", "/api/open/user/v2", (200, {"errorCode": "0000", "message": "ok"}, "")), ]) outcome = await runner.upsert( person_email="a@pingcap.com", payload={ "employeeID": "P00001", "fullName": "Alice", "email": "a@pingcap.com", "custDeptNumber": "1809819", "companyCode": "001", "entryTime": "2023-04-01", "mobile": "13800000000", }, prior_external_id="P00001", ) assert outcome.status == "success", outcome.error put = next(c for c in calls.calls if c[0] == "PUT") # Documented shape is 格式:yyyy-MM-dd (both V2 samples are date-only). assert put[2]["entryTime"] == "2023-04-01" # The read-contract name must never be on the wire — it is the exact # key the vendor ignores. assert "entryDate" not in put[2] assert put[2]["mobile"] == "13800000000" assert isinstance(_ScriptedCalls, type)