"""Unit tests for the email local-part derivation in ``account_provisioner._local_part_from_name``. This is the most fragile / critical bit of the joiner flow — pinyin conversion + sanitization + length capping. Bugs here ship the new hire either a malformed mailbox or a mailbox with someone else's identity buried in it (think ``zhang.WEI``-vs-``zhang.wei`` collisions). **Convention** (PingCAP, 2026-06-02): emails are ``given.family`` (名.姓), the reverse of the SF / displayName order ``family given``. Examples: * ``陈伟`` (姓=陈, 名=伟) → ``wei.chen`` * ``张三丰`` (姓=张, 名=三丰) → ``sanfeng.zhang`` * ``欧阳娜娜`` (姓=欧阳, 名=娜娜) → ``nana.ouyang`` (via SF firstName/lastName path) * ``Tom Smith`` → ``tom.smith`` * ``Chi Yeung Yip`` (romanised HK) → ``chiyeung.yip`` (given collapses) The legacy display-name-only path can't tell which character is the family for a compound surname (欧阳 / 司马), so it falls back to "first token is family" heuristic. The SF firstName/lastName path is the only authoritative answer for compound family names. **One-dot invariant** (P10018 regression, 2026-07-19): a romanised multi-token name with no SF firstName/lastName (e.g. HK ``Chi Yeung Yip``) collapses its given/middle tokens into one so the mailbox keeps exactly one dot — ``chiyeung.yip``, never ``chi.yeung.yip``. A genuine Western middle name collapses the same way (``tomrobert.smith``). """ from __future__ import annotations import pytest from app.core.corporate_domains import DEFAULT_CORPORATE_EMAIL_DOMAINS from app.domains.google_workspace.account_provisioner import ( _local_part_from_hris_email, _local_part_from_name, _name_tokens, ) @pytest.mark.parametrize( "display_name, name_pinyin, employee_no, expected", [ # ── Legacy path (no SF firstName/lastName populated yet) ── # 2-syllable Chinese name 陈伟 — 姓=陈, 名=伟 → wei.chen. ("陈伟", "chen wei", None, "wei.chen"), # Same name without pre-split pinyin — function re-pinyinises # display_name per character and applies the same rule. ("陈伟", "chenwei", None, "wei.chen"), # 3-syllable Chinese name 张三丰 — 姓=张, 名=三丰. Legacy # heuristic treats first token as family, concatenates the # remaining "given" tokens, and emits ``given_concat.family``. ("张三丰", "zhang san feng", None, "sanfeng.zhang"), # Same 3-syllable name with no HRIS-supplied pinyin — CJK # display_name path produces the same 3 tokens via lazy_pinyin, # then the concat rule applies. ("张三丰", None, None, "sanfeng.zhang"), # 4-syllable name 欧阳娜娜 — real split is 复姓 欧阳 + 双字名 # 娜娜. The legacy heuristic can't detect compound family # names, so it falls back to "first token = family" # → ``yangnana.ou``. NOT correct for compound surnames; the # SF firstName/lastName path is the only correct answer here # (verified below in ``test_first_last_name_path``). ("欧阳娜娜", "ou yang na na", None, "yangnana.ou"), # Pure Western 2-token name — already ``given.family`` in # display order; no reversal applies. ("Tom Smith", None, None, "tom.smith"), # Romanised HK / overseas name — given=``Chi Yeung``, family= # ``Yip``. No CJK in display_name and SF leaves firstName/lastName # blank, so this is a pure-Latin 3-token name. The given syllables # collapse into ONE token → ``chiyeung.yip`` (one dot), NOT the # buggy ``chi.yeung.yip``. Prod regression P10018, 2026-07-19. ("Chi Yeung Yip", "chi yeung yip", None, "chiyeung.yip"), # Same, sourced purely from display_name (no name_pinyin). ("Chi Yeung Yip", None, None, "chiyeung.yip"), # Western 3-token name (middle name) — collapses too. The # "exactly one dot, family on the right" invariant wins over # preserving the middle name as a separate dotted token; these are # rare and the address stays valid. ("Tom Robert Smith", None, None, "tomrobert.smith"), # Mixed name "Andy 陈" — Latin given + single-CJK family. # Output is ``given.family`` = ``andy.chen``. ("Andy 陈", None, None, "andy.chen"), # Parenthetical suffix gets stripped. ("陈伟 (Beijing)", "chen wei", None, "wei.chen"), # Pure punctuation in display_name → fallback to employee_no. ("---", None, "E1234", "e1234"), # All inputs blank → empty (caller treats as failure). ("", "", "", ""), # Placeholder names are data state, never a corporate mailbox. ("Unknown", "unknown", "P10020", ""), ("N/A", None, "P10020", ""), # Single-token name (Mononymous) — one token, no dot. ("Madonna", None, None, "madonna"), ], ) def test_local_part_from_name( display_name, name_pinyin, employee_no, expected, ): assert _local_part_from_name( display_name=display_name, name_pinyin=name_pinyin, employee_no=employee_no, max_length=30, ) == expected @pytest.mark.parametrize( "first_name, last_name, expected", [ # Chinese name with proper given/family split from SF. ("伟", "陈", "wei.chen"), # 3-syllable name (1-char family + 2-char given). SF path is # authoritative: pinyinises each side independently. ("三丰", "张", "sanfeng.zhang"), # Compound 2-char family (欧阳) — the SF path handles this # correctly, unlike the legacy display-name pinyin heuristic. ("娜娜", "欧阳", "nana.ouyang"), # Latin name — values pass through lowercased. ("Tom", "Smith", "tom.smith"), # Mixed cases. ("Andy", "陈", "andy.chen"), # Whitespace + parens get trimmed/stripped. (" Tom ", " Smith (Sales) ", "tom.smith"), ], ) def test_first_last_name_path_produces_given_family( first_name, last_name, expected, ): """The authoritative path: when SF gave us firstName + lastName explicitly, build ``given.family`` deterministically and skip the pinyin-order heuristic entirely. This is the only correct path for compound family names like 欧阳 / 司马 / 上官 and for multi-character given names.""" assert _local_part_from_name( display_name=None, name_pinyin=None, employee_no=None, first_name=first_name, last_name=last_name, max_length=30, ) == expected def test_first_last_name_falls_back_when_either_side_blank(): """If only one of firstName/lastName is populated, the authoritative path bails and the legacy display_name path takes over — better to produce a half-correct legacy email than a half-name (e.g. ``wei.``).""" assert _local_part_from_name( display_name="陈伟", name_pinyin=None, employee_no=None, first_name="伟", last_name=None, max_length=30, ) == "wei.chen" def test_first_last_name_treats_whitespace_only_as_blank(): """Whitespace-only firstName or lastName is treated as missing — fall through to legacy path.""" assert _local_part_from_name( display_name="陈伟", name_pinyin=None, employee_no=None, first_name=" ", last_name="陈", max_length=30, ) == "wei.chen" def test_first_last_name_both_blank_returns_empty(): """Both sides empty + no display_name + no employee_no → returns empty (caller surfaces a failed-provisioning audit).""" assert _local_part_from_name( display_name=None, name_pinyin=None, employee_no=None, first_name="", last_name="", max_length=30, ) == "" def test_local_part_truncates_overlong_family_name(): # Family name longer than the budget gets truncated; the given # name + dot survive intact. Tokens are ``[given, family]`` # post-reversal, so the LAST token (family) is the one truncated. out = _local_part_from_name( display_name=None, name_pinyin="aaaaaaaaaaaaaaaaaaaa anne", employee_no=None, max_length=15, ) # name_pinyin tokens [aaa(x20), anne] → CJK reverse+concat rule # (HRIS only writes name_pinyin for Chinese-name employees) → # ``family=aaa(x20)``, ``given=anne`` → result ``[anne, aaa(x20)]`` # → joined "anne.aaaa..." (head=anne, family truncated to fit). assert out.startswith("anne.") assert len(out) == 15 def test_name_tokens_drops_non_letters(): # pypinyin returns whitespace tokens too — must be stripped. # Latin name: no reversal needed. tokens = _name_tokens(display_name="Tom Smith", name_pinyin=None) assert tokens == ["tom", "smith"] def test_name_tokens_romanised_3_token_collapses_given(): # Romanised HK name via display_name only → given collapses, family # (last token) kept → one dot. Prod regression P10018. assert _name_tokens( display_name="Chi Yeung Yip", name_pinyin=None, ) == ["chiyeung", "yip"] def test_name_tokens_romanised_3_token_from_pinyin_path_collapses(): # Same via the name_pinyin-with-spaces + Latin display path (Path 1). assert _name_tokens( display_name="Chi Yeung Yip", name_pinyin="chi yeung yip", ) == ["chiyeung", "yip"] def test_name_tokens_mixed_latin_middle_plus_cjk_family_collapses(): # ``Andy Robert 陈`` → Latin given side collapses, CJK family kept. assert _name_tokens( display_name="Andy Robert 陈", name_pinyin=None, ) == ["andyrobert", "chen"] def test_name_tokens_chinese_display_name_reverses_to_given_family(): tokens = _name_tokens(display_name="陈伟", name_pinyin=None) assert tokens == ["wei", "chen"] def test_name_tokens_3_syllable_chinese_concats_given(): tokens = _name_tokens(display_name="张三丰", name_pinyin=None) assert tokens == ["sanfeng", "zhang"] def test_name_tokens_handles_pure_pinyin_no_space(): # name_pinyin without spaces falls through to fallback path. tokens = _name_tokens(display_name=None, name_pinyin="chenwei") assert tokens == ["chenwei"] def test_local_part_caps_at_max_length(): # Both head and family overlong — overall capped strictly. out = _local_part_from_name( display_name=None, name_pinyin="aaaaaa bbbbbb cccccc", employee_no=None, max_length=10, ) assert len(out) == 10 # ── HRIS-email local part: corporate-domain guard ─────────────────── # SF ``User.email`` is the *primary* email — for a fresh hire usually a # PERSONAL address (the corporate mailbox doesn't exist yet). Only a # company-owned domain (or a bare 邮箱名) may be honoured as the # authoritative work_email; anything else must fall through to the # given.family pinyin derivation. Regression for prod 2026-06-29 where # P10008 黄浚源 was minted as ``1030263320@pingcap.com`` from a QQ-number # personal email that had leaked into work_email. @pytest.mark.parametrize( "work_email, expected", [ # Real corporate addresses — honoured (local part, dots preserved). ("wei.chen@pingcap.com", "wei.chen"), ("Wei.Chen@PingCAP.com", "wei.chen"), # case-insensitive ("nana@pingcap-c.com", "nana"), # contractor domain ("x@tidb.io", "x"), # Bare local part (HR onboarding 邮箱名 shape) — no domain to vet, # always honoured. ("san.zhang", "san.zhang"), # Personal / non-corporate domains — rejected (empty → caller # falls through to name derivation). ("1030263320@qq.com", ""), # P10008 regression ("nina.lim168@gmail.com", ""), # P9999 regression ("a@pingcap.cn", ""), # NOT a mail domain ("x@partner.gaiaworks.cn", ""), # SAP vendor # SF placeholder — rejected. ("P10008@successfactors.local", ""), ("unknown@pingcap.com", ""), ("UNKNOWN", ""), ("null@pingcap.com", ""), ("n/a", ""), # Empty / None. ("", ""), (None, ""), ], ) def test_local_part_from_hris_email_corporate_guard(work_email, expected): assert _local_part_from_hris_email( work_email, max_length=30, corporate_domains=DEFAULT_CORPORATE_EMAIL_DOMAINS, ) == expected def test_p10008_personal_email_falls_through_to_name_derivation(): """End-to-end of the prod bug: a QQ-number personal email in work_email must NOT become the mailbox; with firstName/lastName present the provisioner derives ``junyuan.huang`` instead.""" # Step 1 (HRIS email) rejects the personal address. step1 = _local_part_from_hris_email( "1030263320@qq.com", max_length=30, corporate_domains=DEFAULT_CORPORATE_EMAIL_DOMAINS, ) assert step1 == "" # Step 2 (name) yields the correct given.family. derived = _local_part_from_name( display_name="黄浚源 Junyuan Huang", name_pinyin="huangjunyuan junyuan huang", employee_no="P10008", first_name="浚源", last_name="黄", max_length=30, ) assert derived == "junyuan.huang"