"""Pure-data unit tests for the Navan-specific mapping additions. Covers: - ``strip_company_prefix`` transform: drops ``\\d+[_\\-.]`` prefixes, is left-anchored, preserves whitespace-trimmed remainder. - ``department_combined`` derived field: L2/L3/L4 join with empty segments dropped, plays well with ``org_path_drop_root``. - ``trace_render`` per-rule explanation: covers transform, value_map miss, default fallback, dropped path. - Navan preset shape: no ``title`` rule, ``department`` reads from ``{department_combined}``, ``division`` carries the prefix-strip transform. All tests are pure data — no DB or HTTP — so they run in the unit-test tier without fixtures. """ from __future__ import annotations from types import SimpleNamespace import pytest from app.domains.identity_sync.mapping import ( SUPPORTED_TRANSFORMS, MappingConfig, MappingError, MappingRule, _apply_transform, build_person_context, render_payload, render_value, trace_render, ) from app.domains.identity_sync.presets import ( SCIM_DEFAULT_MAPPING_RULES, get_preset, ) def _make_person(**overrides): """Build a SimpleNamespace shaped like IdentityPerson for build_person_context (which uses direct attribute reads, not ORM). """ base = { "work_email": "john.smith@pingcap.com", "employee_no": "E001", "name_pinyin": None, "display_name": "John Smith", "job_title": "Engineer", "country": "USA", "city": "Sunnyvale", "employment_status": "active", "hire_date": None, "termination_date": None, "mobile_phone": None, # ISO-2 of the mobile's own country (migration 391) — mirrors the # IdentityPerson column build_person_context reads directly. "mobile_country_code": None, "cost_center": None, "work_location_name": "Sunnyvale", "work_location_code": "US-CA", "company_name": "01_PingCAP(US),Inc.", "company_code": "PCUS", # SF picklist codes (migration 344) — mirror the IdentityPerson # columns build_person_context now reads via direct attribute access. "gender": None, "employee_type_code": None, "office_code": None, # GitHub handle (migration 369) — SF cust_Other.cust_id. "github_id": None, # SF-verbatim labels (Google Employee_Details) — mirror the # IdentityPerson columns build_person_context reads directly. "office_mail_group": None, "job_function": None, "job_family": None, "residence_country": None, } base.update(overrides) return SimpleNamespace(**base) # ── strip_company_prefix ──────────────────────────────────────────── class TestStripCompanyPrefix: def test_registered_in_supported_transforms(self): assert "strip_company_prefix" in SUPPORTED_TRANSFORMS @pytest.mark.parametrize("inp,expected", [ ("01_PingCAP(US),Inc.", "PingCAP(US),Inc."), ("1_Foo", "Foo"), ("99-Bar Ltd.", "Bar Ltd."), ("12.Baz", "Baz"), (" 03_Spaced ", "Spaced"), # No-prefix inputs pass through unchanged. ("PingCAP Japan KK", "PingCAP Japan KK"), ("", ""), # Numeric chunk mid-string is NOT stripped — anchor at start. ("Foo 01_Bar", "Foo 01_Bar"), # Multiple prefixes: only the first is stripped (loud-failure # behaviour for malformed HRIS data). ("01_2_PingCAP", "2_PingCAP"), ]) def test_strips_numeric_prefix(self, inp, expected): assert _apply_transform(inp, "strip_company_prefix") == expected # ── cjk_name transform (汇联易 fullName) ───────────────────────────── class TestCjkNameTransform: """Extracts the Chinese run from a bilingual display_name for 汇联易; English-only names pass through. Pins the 2026-06-16 fix where 汇联易 fullName used the email-derived English ``full_name`` and so never reflected a 汉字 rename (the user's name edit silently no-op'd).""" def test_registered(self): assert "cjk_name" in SUPPORTED_TRANSFORMS @pytest.mark.parametrize("inp,expected", [ ("冯帅 Shuai Feng", "冯帅"), ("倪捷 Ni Jie", "倪捷"), ("朱赟 Claire zhu", "朱赟"), # No CJK → English display_name passes through unchanged. ("John Smith", "John Smith"), ("Shuai Feng", "Shuai Feng"), ("", ""), # Leading/trailing whitespace trimmed; CJK run extracted. (" 张三 John ", "张三"), # 2026-06-17 fix: a space between surname and given name (compound # surname or surname/given split) must NOT truncate to the surname — # the leading CJK block is bridged across internal whitespace. ("冯 帅 Shuai Feng", "冯帅"), ("欧阳 娜娜 Nana", "欧阳娜娜"), # ...but a trailing / parenthetical CJK remark after the English name # must NOT be concatenated into the extracted name. ("Shuai Feng 冯帅 (财务部)", "冯帅"), ("冯帅 Shuai Feng - 已离职", "冯帅"), ]) def test_extracts_cjk_or_falls_back(self, inp, expected): assert _apply_transform(inp, "cjk_name") == expected def test_huilianyi_fullname_rule_uses_display_name_cjk(self): """The 汇联易 user preset must render fullName from display_name via cjk_name — NOT from the email-derived full_name.""" from app.domains.identity_sync.mapping import ( MappingConfig, render_payload, ) from app.domains.identity_sync.presets import ( HUILIANYI_USER_MAPPING_RULES, ) person = _make_person( work_email="shuai.feng@pingcap.com", display_name="冯帅 Shuai Feng", name_pinyin="fengshuai shuai feng", ) ctx = build_person_context( person, org_path=None, manager_email=None, manager_name=None, ) payload = render_payload( MappingConfig.from_dict({"rules": HUILIANYI_USER_MAPPING_RULES}), ctx, ) # The Chinese name, not "Shuai Feng" (the old email-derived value). assert payload["fullName"] == "冯帅" class TestDisplayNameCjkToken: """``display_name_cjk`` ctx token = robust CJK extraction, BLANK for foreign hires (so a NetSuite-style name rule keeps the vendor's English name). User decision 2026-06-24: NetSuite keeps Chinese names.""" def test_cjk_person_yields_chinese(self): ctx = build_person_context( _make_person(display_name="朱晓鹏 Xiaopeng Zhu"), org_path=None, manager_email=None, manager_name=None, ) assert ctx["display_name_cjk"] == "朱晓鹏" def test_foreign_person_yields_blank(self): # English-only display_name → blank (NOT passthrough) so the # consuming firstName rule drops and preserves the vendor name. ctx = build_person_context( _make_person(display_name="John Smith"), org_path=None, manager_email=None, manager_name=None, ) assert ctx["display_name_cjk"] == "" def test_foreign_name_with_trailing_cjk_remark_yields_blank(self): # Regression: a foreign hire whose display_name carries a trailing # CJK remark ("John Smith (财务部)") must NOT leak the remark into # firstName. Only a CJK-LEADING display_name is treated as a name. for dn in ("John Smith (财务部)", "Aaron Alvarez - 已离职", "Jane 测试"): ctx = build_person_context( _make_person(display_name=dn), org_path=None, manager_email=None, manager_name=None, ) assert ctx["display_name_cjk"] == "", dn def test_netsuite_supervisor_and_department_render_and_drop(self): # supervisor (direct manager) + department link render ONLY when the # orchestrator injected the resolved token; absent → field drops # (vendor value preserved). The dept-ROLE fields (leader/budget/hrbp) # are no longer written at all — verified 2026-06-25 that Helix's # derivation disagrees with the authoritative values too often. from app.domains.identity_sync.mapping import ( MappingConfig, render_payload, ) from app.domains.identity_sync.presets import ( NETSUITE_USER_MAPPING_RULES, ) cfg = MappingConfig.from_dict({"rules": NETSUITE_USER_MAPPING_RULES}) ctx = build_person_context( _make_person(employee_no="P0876", display_name="朱晓鹏 X Zhu"), org_path=None, manager_email=None, manager_name=None, ) base = render_payload(cfg, ctx) # work_num always renders (= 工号); supervisor + department drop # without injected tokens; role fields never present. assert base["custentity_swc_work_num"] == "P0876" assert "supervisor" not in base assert "department" not in base assert "custentity_swc_depart_leader" not in base assert "custentity_swc_budget_owner" not in base assert "custentity_swc_hrbp" not in base # inject resolved tokens (as the NS sweep does) → reference objects. ctx["ns_supervisor_id"] = "246" ctx["ns_department_code"] = "3963958" out = render_payload(cfg, ctx) assert out["supervisor"] == {"id": "246"} assert out["department"] == {"externalId": "3963958"} # role fields still never appear, even with other tokens injected. assert "custentity_swc_depart_leader" not in out assert "custentity_swc_hrbp" not in out def test_netsuite_subsidiary_value_map(self): # company_code → NetSuite subsidiary internal id (overseas confirmed, # mainland China consolidated to 32 北京星云深图). Unmapped → drops. from app.domains.identity_sync.mapping import ( MappingConfig, render_payload, ) from app.domains.identity_sync.presets import ( NETSUITE_USER_MAPPING_RULES, ) cfg = MappingConfig.from_dict({"rules": NETSUITE_USER_MAPPING_RULES}) for code, sub in [("DP00001", "6"), ("DP00004", "9"), ("DP00012", "32"), ("DP00020", "32")]: out = render_payload(cfg, build_person_context( _make_person(employee_no="P0001", display_name="X", company_code=code), org_path=None, manager_email=None, manager_name=None, )) assert out["subsidiary"] == {"id": sub}, (code, out.get("subsidiary")) def test_netsuite_firstname_rule_chinese_vs_foreign(self): from app.domains.identity_sync.mapping import ( MappingConfig, render_payload, ) from app.domains.identity_sync.presets import ( NETSUITE_USER_MAPPING_RULES, ) cfg = MappingConfig.from_dict({"rules": NETSUITE_USER_MAPPING_RULES}) # Chinese hire → firstName = 中文名, lastName NOT in payload. zh = render_payload(cfg, build_person_context( _make_person(employee_no="P0876", display_name="朱晓鹏 Xiaopeng Zhu"), org_path=None, manager_email=None, manager_name=None, )) assert zh["firstName"] == "朱晓鹏" assert "lastName" not in zh # preserved on the vendor side # Foreign hire → no CJK run, so cjk_name falls back to the full # display_name. firstName MUST be present: netsuite_create_missing # rejects a nameless employee create ("请输入名称"), so an empty # firstName broke every foreign new-hire create in the cutover. en = render_payload(cfg, build_person_context( _make_person(employee_no="P0001", display_name="John Smith"), org_path=None, manager_email=None, manager_name=None, )) assert en["firstName"] == "John Smith" assert "lastName" not in en # ── HRBP context field ────────────────────────────────────────────── class TestHrbpContext: """``hrbp_email`` / ``hrbp_name`` flow through the render context so a mapping rule (Feishu's ``hrbp_user_id_email``) can reference them.""" def test_hrbp_fields_exposed(self): ctx = build_person_context( _make_person(), org_path=None, manager_email="boss@pingcap.com", manager_name="Boss", hrbp_email="claire.zhu@pingcap.com", hrbp_name="Claire Zhu", ) assert ctx["hrbp_email"] == "claire.zhu@pingcap.com" assert ctx["hrbp_name"] == "Claire Zhu" def test_hrbp_absent_renders_empty_string(self): # No HRBP declared in SF → empty string (never the literal "None"), # so the Feishu rule renders empty and the runner omits the slot. ctx = build_person_context( _make_person(), org_path=None, manager_email=None, manager_name=None, ) assert ctx["hrbp_email"] == "" assert ctx["hrbp_name"] == "" # ── department_combined ───────────────────────────────────────────── class TestDepartmentCombined: def test_three_levels_joined(self): person = _make_person() ctx = build_person_context( person, org_path="PingCAP/Engineering/Cloud/Platform/SRE", manager_email=None, manager_name=None, org_path_drop_root=["PingCAP"], ) # After drop_root: ["Engineering", "Cloud", "Platform", "SRE"] # L1=Engineering, L2=Cloud, L3=Platform, L4=SRE assert ctx["department_l1"] == "Engineering" assert ctx["department_l2"] == "Cloud" assert ctx["department_l3"] == "Platform" assert ctx["department_l4"] == "SRE" assert ctx["department_combined"] == "Cloud / Platform / SRE" def test_two_levels_skips_empty(self): person = _make_person() ctx = build_person_context( person, org_path="PingCAP/Finance/Tax", manager_email=None, manager_name=None, org_path_drop_root=["PingCAP"], ) # L2=Tax, L3/L4 empty → combined is just "Tax" assert ctx["department_l2"] == "Tax" assert ctx["department_l3"] == "" assert ctx["department_combined"] == "Tax" def test_only_l1_empty_combined(self): person = _make_person() ctx = build_person_context( person, org_path="PingCAP/Sales", manager_email=None, manager_name=None, org_path_drop_root=["PingCAP"], ) assert ctx["department_l1"] == "Sales" assert ctx["department_l2"] == "" assert ctx["department_combined"] == "" def test_l3_present_but_l2_missing_renders_what_exists(self): # Defensive — this shouldn't happen in real HRIS data, but the # join should be robust against it. With only L3 populated # (i.e. the levels list is short), L3 wouldn't be populated; # if we ever fed a sparse path the join skips empties. # We synthesise by directly invoking _join semantics via a # mock context: the function logic above filters empties. person = _make_person() ctx = build_person_context( person, org_path="PingCAP/L1/L2/L3/L4", manager_email=None, manager_name=None, org_path_drop_root=["PingCAP"], ) assert ctx["department_combined"] == "L2 / L3 / L4" # ── name derivation: givenName/familyName never empty ─────────────── class TestNameDerivationNeverEmpty: """Navan hard-rejects a create when givenName or familyName is blank ("User first name and last name are mandatory"). The derivation must always produce both halves non-empty, even for degenerate single-token records. Regression for prod Navan run 2026-05-26 (person "Hice", hice@pingcap.com).""" def _ctx(self, **overrides): person = _make_person(**overrides) return build_person_context( person, org_path=None, manager_email=None, manager_name=None, ) def test_single_token_display_name_no_dot_no_pinyin(self): # The exact prod failure: one-word display_name, email local # has no dot, name_pinyin is null. ctx = self._ctx( work_email="hice@pingcap.com", display_name="Hice", name_pinyin=None, ) assert ctx["first_name"] assert ctx["last_name"] assert ctx["full_name"] def test_dotted_email_still_splits_first_last(self): ctx = self._ctx( work_email="john.smith@pingcap.com", display_name="John Smith", ) assert ctx["first_name"] == "John" assert ctx["last_name"] == "Smith" def test_multi_token_full_name_fallback_splits(self): # No dotted email, no pinyin, but a multi-word display_name: # first = first token, last = remainder. ctx = self._ctx( work_email="abc@pingcap.com", display_name="Mary Jane Watson", name_pinyin=None, ) assert ctx["first_name"] == "Mary" assert ctx["last_name"] == "Jane Watson" def test_empty_everything_does_not_crash(self): ctx = self._ctx( work_email="x@pingcap.com", display_name="", name_pinyin=None, ) assert ctx["first_name"] assert ctx["last_name"] # ── trace_render ──────────────────────────────────────────────────── class TestTraceRender: def test_basic_render_trace(self): cfg = MappingConfig(rules=[ MappingRule( source_template="{first_name}", target_field="name.givenName", ), ]) person = _make_person() ctx = build_person_context( person, org_path=None, manager_email=None, manager_name=None, ) trace = trace_render(cfg, ctx) assert len(trace) == 1 entry = trace[0] assert entry["source_template"] == "{first_name}" assert entry["target_field"] == "name.givenName" assert entry["raw"] == "John" assert entry["rendered"] == "John" assert entry["dropped"] is False assert entry["used_default"] is False def test_strip_company_prefix_in_trace(self): cfg = MappingConfig(rules=[ MappingRule( source_template="{company_name}", target_field="division", transform=["strip_company_prefix"], ), ]) person = _make_person(company_name="01_PingCAP(US),Inc.") ctx = build_person_context( person, org_path=None, manager_email=None, manager_name=None, ) trace = trace_render(cfg, ctx) assert trace[0]["raw"] == "01_PingCAP(US),Inc." assert trace[0]["after_transforms"] == "PingCAP(US),Inc." assert trace[0]["rendered"] == "PingCAP(US),Inc." def test_value_map_miss_drops_unless_default(self): cfg = MappingConfig(rules=[ MappingRule( source_template="{country}", target_field="region", value_map={"USA": "NA", "CHN": "APAC"}, ), ]) person = _make_person(country="ZAF") # not in map ctx = build_person_context( person, org_path=None, manager_email=None, manager_name=None, ) trace = trace_render(cfg, ctx) assert trace[0]["value_map_hit"] is False assert trace[0]["rendered"] is None assert trace[0]["dropped"] is True def test_value_map_hit_renders(self): cfg = MappingConfig(rules=[ MappingRule( source_template="{country}", target_field="region", value_map={"USA": "NA"}, ), ]) person = _make_person(country="USA") ctx = build_person_context( person, org_path=None, manager_email=None, manager_name=None, ) trace = trace_render(cfg, ctx) assert trace[0]["value_map_hit"] is True assert trace[0]["rendered"] == "NA" assert trace[0]["dropped"] is False def test_default_fills_empty(self): cfg = MappingConfig(rules=[ MappingRule( source_template="{cost_center}", target_field="costCenter", default="UNASSIGNED", ), ]) person = _make_person(cost_center=None) ctx = build_person_context( person, org_path=None, manager_email=None, manager_name=None, ) trace = trace_render(cfg, ctx) assert trace[0]["raw"] == "" assert trace[0]["rendered"] == "UNASSIGNED" assert trace[0]["dropped"] is False assert trace[0]["used_default"] is True # ── Navan preset shape ────────────────────────────────────────────── class TestNavanPresetShape: def test_navan_preset_drops_title(self): navan = get_preset("navan") assert navan is not None target_fields = {r["target_field"] for r in navan.mapping_rules} assert "title" not in target_fields, ( "Navan preset must not push job_title — see PingCAP policy" ) def test_navan_department_uses_combined(self): navan = get_preset("navan") dept_rule = next( r for r in navan.mapping_rules if r["target_field"].endswith(".department") ) assert dept_rule["source_template"] == "{department_combined}" def test_navan_division_carries_strip_prefix(self): navan = get_preset("navan") div_rule = next( r for r in navan.mapping_rules if r["target_field"].endswith(".division") ) xf = div_rule.get("transform") if isinstance(xf, str): assert xf == "strip_company_prefix" else: assert "strip_company_prefix" in (xf or []) def test_generic_scim_preset_unchanged_by_navan_split(self): """Generic SCIM preset still uses business_unit + raw company name + job_title. The Navan changes must not regress the vendor-agnostic SCIM preset (other downstream systems we target may want job_title and full L1 BU).""" generic = next( r for r in SCIM_DEFAULT_MAPPING_RULES if r["target_field"] == "title" ) assert generic["source_template"] == "{job_title}" bu_rule = next( r for r in SCIM_DEFAULT_MAPPING_RULES if r["target_field"].endswith(".department") ) assert bu_rule["source_template"] == "{business_unit}" div_rule = next( r for r in SCIM_DEFAULT_MAPPING_RULES if r["target_field"].endswith(".division") ) assert div_rule.get("transform") in (None, [], "") def test_navan_preset_validates(self): """The preset's mapping rules must parse cleanly through MappingConfig — the same validation the create-target API runs. Regression guard for malformed entries.""" navan = get_preset("navan") cfg = MappingConfig.from_dict({"rules": navan.mapping_rules}) assert len(cfg.rules) == len(navan.mapping_rules) def test_navan_preset_renders_end_to_end(self): """Sanity: rendering a real-ish person through the Navan preset produces non-empty division (with prefix stripped) and department (combined).""" navan = get_preset("navan") cfg = MappingConfig.from_dict({"rules": navan.mapping_rules}) person = _make_person( company_name="01_PingCAP(US),Inc.", ) ctx = build_person_context( person, org_path="PingCAP/Engineering/Cloud/Platform", manager_email="boss@pingcap.com", manager_name="The Boss", org_path_drop_root=["PingCAP"], ) payload = render_payload(cfg, ctx) ext = payload["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] assert ext["division"] == "PingCAP(US),Inc." assert ext["department"] == "Cloud / Platform" # job_title is omitted entirely assert "title" not in payload # ordinary manager flows through verbatim assert ext["manager"]["value"] == "boss@pingcap.com" def test_navan_remaps_liuqi_manager_to_qiufang(self): """Reports whose HRIS manager is liuqi@pingcap.com are pushed to Navan under qiufang@pingcap.com; every other manager and the no-manager case are untouched.""" navan = get_preset("navan") cfg = MappingConfig.from_dict({"rules": navan.mapping_rules}) def _mgr_value(manager_email): ctx = build_person_context( _make_person(), org_path="PingCAP/Engineering", manager_email=manager_email, manager_name="x", org_path_drop_root=["PingCAP"], ) ext = render_payload(cfg, ctx).get( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", {} ) return ext.get("manager", {}).get("value") if "manager" in ext else None # the one override assert _mgr_value("liuqi@pingcap.com") == "qiufang@pingcap.com" # everyone else passes through unchanged assert _mgr_value("alice@pingcap.com") == "alice@pingcap.com" # no manager → field absent, not blanked assert _mgr_value(None) is None class TestValueMapPassthrough: """The ``value_map_passthrough`` flag flips a value_map from strict translation (unmapped → empty) to override semantics (unmapped → unchanged).""" def _rule(self, **extra): return MappingRule.from_dict({ "source_template": "{manager_email}", "target_field": "x", "value_map": {"a@x.com": "b@x.com"}, **extra, }) def test_strict_default_blanks_unmapped(self): rule = self._rule() assert render_value(rule, {"manager_email": "a@x.com"}) == "b@x.com" # unmapped → empty → dropped (no default) assert render_value(rule, {"manager_email": "c@x.com"}) is None def test_passthrough_keeps_unmapped(self): rule = self._rule(value_map_passthrough=True) assert render_value(rule, {"manager_email": "a@x.com"}) == "b@x.com" assert render_value(rule, {"manager_email": "c@x.com"}) == "c@x.com" # empty input still drops (passthrough of "" → None) assert render_value(rule, {"manager_email": ""}) is None def test_passthrough_survives_roundtrip(self): cfg = MappingConfig.from_dict({"rules": [ self._rule(value_map_passthrough=True).to_dict() ]}) rt = MappingConfig.from_dict(cfg.to_dict()) assert rt.rules[0].value_map_passthrough is True def test_passthrough_without_value_map_rejected(self): with pytest.raises(MappingError): MappingRule.from_dict({ "source_template": "{work_email}", "target_field": "x", "value_map_passthrough": True, }) def test_trace_render_reflects_passthrough(self): cfg = MappingConfig.from_dict({"rules": [ self._rule(value_map_passthrough=True).to_dict() ]}) trace = trace_render(cfg, {"manager_email": "c@x.com"}) assert trace[0]["after_value_map"] == "c@x.com" assert trace[0]["value_map_hit"] is False assert trace[0]["rendered"] == "c@x.com" class TestEmptyValueMapStrict: """Regression: an EMPTY value_map ``{}`` is a present strict map — every source misses → field dropped (or default). The old ``if rule.value_map:`` treated ``{}`` as falsy and leaked the RAW source value (e.g. NetSuite got the Helix company_code ``DP00020`` as a subsidiary id → USER_ERROR). ``None`` = no map = pass through. """ def test_empty_map_drops_field(self): cfg = MappingConfig(rules=[ MappingRule(source_template="{company_code}", target_field="subsidiary.id", value_map={}), ]) out = render_payload(cfg, {"company_code": "DP00020"}) # field dropped — raw code must NOT leak through assert out == {} def test_empty_map_with_default_uses_default(self): cfg = MappingConfig(rules=[ MappingRule(source_template="{company_code}", target_field="subsidiary.id", value_map={}, default="6"), ]) out = render_payload(cfg, {"company_code": "DP00020"}) assert out == {"subsidiary": {"id": "6"}} def test_populated_map_hit(self): cfg = MappingConfig(rules=[ MappingRule(source_template="{company_code}", target_field="subsidiary.id", value_map={"DP00020": "6"}), ]) out = render_payload(cfg, {"company_code": "DP00020"}) assert out == {"subsidiary": {"id": "6"}} def test_none_map_passes_through(self): cfg = MappingConfig(rules=[ MappingRule(source_template="{work_email}", target_field="email", value_map=None), ]) out = render_payload(cfg, {"work_email": "x@pingcap.com"}) assert out == {"email": "x@pingcap.com"} class TestValueMapReachability: """Save-time guard: a value_map whose keys can never match the post- transform value is dead config — a STRICT map then silently blanks the field for the WHOLE population with a green run. The casing transforms are caught at parse time (zero false positives).""" def test_lower_transform_rejects_uppercase_keys(self): with pytest.raises(MappingError, match="never match"): MappingRule.from_dict({ "source_template": "{country}", "target_field": "region", "transform": "lower", "value_map": {"USA": "NA"}, # 'usa' would be looked up }) def test_upper_transform_rejects_lowercase_keys(self): with pytest.raises(MappingError, match="never match"): MappingRule.from_dict({ "source_template": "{country}", "target_field": "region", "transform": "upper", "value_map": {"usa": "NA"}, }) def test_lower_transform_accepts_lowercase_keys(self): rule = MappingRule.from_dict({ "source_template": "{country}", "target_field": "region", "transform": "lower", "value_map": {"usa": "NA"}, }) assert render_value(rule, {"country": "USA"}) == "NA" def test_no_casing_transform_accepts_any_keys(self): # Without a casing transform, mixed-case keys are legitimate. rule = MappingRule.from_dict({ "source_template": "{country}", "target_field": "region", "value_map": {"USA": "NA"}, }) assert render_value(rule, {"country": "USA"}) == "NA" class TestValueMapExplicitBlank: """A value_map entry that intentionally maps a source value to an empty string ("clear this field for X") is a deliberate drop and must NOT be rescued by ``default`` — a hit is distinguished from a STRICT miss.""" def test_explicit_blank_hit_drops_field_not_default(self): rule = MappingRule( source_template="{country}", target_field="region", value_map={"XX": "", "USA": "NA"}, default="FALLBACK", ) # explicit blank mapping → drop (None), default NOT applied assert render_value(rule, {"country": "XX"}) is None # real hit still renders assert render_value(rule, {"country": "USA"}) == "NA" # genuine STRICT miss → default still applies assert render_value(rule, {"country": "ZZ"}) == "FALLBACK" def test_trace_explicit_blank_marks_hit_and_drop(self): cfg = MappingConfig(rules=[ MappingRule(source_template="{country}", target_field="region", value_map={"XX": ""}, default="FALLBACK"), ]) trace = trace_render(cfg, {"country": "XX"}) assert trace[0]["value_map_hit"] is True assert trace[0]["rendered"] is None # dropped, not defaulted