"""Tests for the Feishu-backed HRBP resolver. The resolver walks three Feishu API hops (custom_attrs catalog → leaver user → HRBP user) and writes the resolved id back to ``identity_persons.hrbp_person_id``. We exercise the happy path, the cache short-circuit, and every documented failure-reason branch so the SLA dispatcher can rely on the structured fall-through reasons. Feishu calls are stubbed at the ``FeishuApprovalClient`` method level so the tests don't reach Feishu — every test patches ``list_custom_attrs``, ``resolve_user_id_by_email``, and ``get_user_with_custom_attrs`` to return canned shapes. """ from __future__ import annotations from typing import Any from uuid import uuid4 import pytest from app.identity.person_models import IdentityPerson from app.integrations import feishu_hrbp # ── helpers ──────────────────────────────────────────────── async def _make_person( db_session, *, work_email: str, display_name: str = "x", employment_status: str = "active", deleted: bool = False, hrbp_person_id=None, hris_hrbp_person_id=None, ) -> IdentityPerson: from datetime import UTC, datetime person = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid4().hex[:8]}", work_email=work_email, display_name=display_name, employment_status=employment_status, hrbp_person_id=hrbp_person_id, hris_hrbp_person_id=hris_hrbp_person_id, deleted_at=datetime.now(UTC) if deleted else None, ) db_session.add(person) await db_session.flush() return person @pytest.fixture def feishu_stub(monkeypatch): """Patch the Feishu client + ``get_feishu_config`` so the resolver can be exercised without network. Tests configure the per-call return values via the returned ``Stub`` handle. """ feishu_hrbp._HRBP_ATTR_ID_CACHE.clear() class Stub: custom_attrs: list[dict[str, Any]] = [] users_by_id: dict[str, dict[str, Any]] = {} emails_to_user_id: dict[str, str] = {} list_calls: int = 0 get_user_calls: int = 0 stub = Stub() async def fake_get_feishu_config(**_): return {"app_id": "cli_test", "app_secret": "secret"} async def fake_list_custom_attrs(self): stub.list_calls += 1 return list(stub.custom_attrs) async def fake_resolve_user_id_by_email(self, email): if email not in stub.emails_to_user_id: from app.core.exceptions import AppException raise AppException( code="FEISHU_USER_NOT_FOUND", message=f"no feishu user for {email}", status_code=400, ) return stub.emails_to_user_id[email] async def fake_get_user_with_custom_attrs(self, user_id, *, user_id_type="user_id"): stub.get_user_calls += 1 if user_id not in stub.users_by_id: from app.core.exceptions import AppException raise AppException( code="FEISHU_USER_NOT_FOUND", message=f"no user record for {user_id}", status_code=404, ) return stub.users_by_id[user_id] monkeypatch.setattr(feishu_hrbp, "get_feishu_config", fake_get_feishu_config) from app.approval import feishu_client as fc_mod monkeypatch.setattr( fc_mod.FeishuApprovalClient, "list_custom_attrs", fake_list_custom_attrs, ) monkeypatch.setattr( fc_mod.FeishuApprovalClient, "resolve_user_id_by_email", fake_resolve_user_id_by_email, ) monkeypatch.setattr( fc_mod.FeishuApprovalClient, "get_user_with_custom_attrs", fake_get_user_with_custom_attrs, ) return stub # ── happy path + caching ─────────────────────────────────── @pytest.mark.asyncio async def test_resolves_and_caches_to_column(db_session, feishu_stub): hrbp = await _make_person( db_session, work_email="doris@x.com", display_name="Doris Du", ) leaver = await _make_person( db_session, work_email="olivia@x.com", display_name="Olivia He", ) feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["olivia@x.com"] = "fu-leaver" feishu_stub.users_by_id["fu-leaver"] = { "user_id": "fu-leaver", "email": "olivia@x.com", "custom_attrs": [ {"type": "USER", "id": "C-HRBP", "value": {"user": {"user_id": "fu-doris", "name": "Doris Du"}}}, ], } feishu_stub.users_by_id["fu-doris"] = { "user_id": "fu-doris", "email": "doris@x.com", "custom_attrs": [], } res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id == hrbp.id assert res.reason is None assert res.hrbp_email == "doris@x.com" fresh = await db_session.get(IdentityPerson, leaver.id) assert fresh.hrbp_person_id == hrbp.id # Second call: cached column → no Feishu hits. feishu_stub.get_user_calls = 0 feishu_stub.list_calls = 0 res2 = await feishu_hrbp.resolve_hrbp_for_person(db_session, fresh) assert res2.person_id == hrbp.id assert feishu_stub.get_user_calls == 0 assert feishu_stub.list_calls == 0 @pytest.mark.asyncio async def test_cached_hrbp_no_longer_active_falls_through_to_live( db_session, feishu_stub, ): """A cached HRBP id whose person is now inactive must NOT short- circuit — the resolver re-walks Feishu and rewrites the column.""" inactive_hrbp = await _make_person( db_session, work_email="left@x.com", display_name="Left HRBP", employment_status="left", ) new_hrbp = await _make_person( db_session, work_email="new@x.com", display_name="New HRBP", ) leaver = await _make_person( db_session, work_email="olivia@x.com", display_name="Olivia He", hrbp_person_id=inactive_hrbp.id, ) feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "USER", "i18n_name": [{"locale": "en_us", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["olivia@x.com"] = "fu-leaver" feishu_stub.users_by_id["fu-leaver"] = { "user_id": "fu-leaver", "email": "olivia@x.com", "custom_attrs": [ {"type": "USER", "id": "C-HRBP", "value": {"user": {"user_id": "fu-new"}}}, ], } feishu_stub.users_by_id["fu-new"] = { "user_id": "fu-new", "email": "new@x.com", } res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id == new_hrbp.id fresh = await db_session.get(IdentityPerson, leaver.id) assert fresh.hrbp_person_id == new_hrbp.id @pytest.mark.asyncio async def test_resolves_generic_user_attr_shape(db_session, feishu_stub): """PingCAP's HRBP custom attr is type GENERIC_USER, whose value lives under ``value.generic_user.id`` (NOT ``value.user.user_id``). Regression for the prod bug where every HRBP read returned None and leaver-SLA escalations silently fell back to the it_admin pool. Shape pinned to the live wire format verified 2026-06-16: {"id": "C-...", "type": "GENERIC_USER", "value": {"generic_user": {"id": "", "type": 1}}} """ hrbp = await _make_person( db_session, work_email="doris@x.com", display_name="Doris Du", ) leaver = await _make_person( db_session, work_email="olivia@x.com", display_name="Olivia He", ) feishu_stub.custom_attrs = [ {"id": "C-7143156434032312324", "type": "GENERIC_USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["olivia@x.com"] = "fu-leaver" feishu_stub.users_by_id["fu-leaver"] = { "user_id": "fu-leaver", "email": "olivia@x.com", "custom_attrs": [ {"type": "GENERIC_USER", "id": "C-7143156434032312324", "value": {"generic_user": {"id": "fu-doris", "type": 1}}}, ], } feishu_stub.users_by_id["fu-doris"] = { "user_id": "fu-doris", "email": "doris@x.com", "custom_attrs": [], } res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id == hrbp.id assert res.reason is None assert res.hrbp_email == "doris@x.com" def test_extract_hrbp_user_id_both_shapes(): """Unit-pin both envelope shapes on the pure extractor.""" # GENERIC_USER (PingCAP live shape) generic = { "custom_attrs": [ {"id": "C-HRBP", "type": "GENERIC_USER", "value": {"generic_user": {"id": "fu-generic", "type": 1}}}, ], } assert feishu_hrbp._extract_hrbp_user_id(generic, "C-HRBP") == "fu-generic" # USER (legacy / other tenants) user = { "custom_attrs": [ {"id": "C-HRBP", "type": "USER", "value": {"user": {"user_id": "fu-user"}}}, ], } assert feishu_hrbp._extract_hrbp_user_id(user, "C-HRBP") == "fu-user" # Empty / missing value → None empty = {"custom_attrs": [{"id": "C-HRBP", "value": {}}]} assert feishu_hrbp._extract_hrbp_user_id(empty, "C-HRBP") is None @pytest.mark.asyncio async def test_prefers_sf_hris_hrbp_column_no_feishu(db_session, feishu_stub): """When ``hris_hrbp_person_id`` (SF-authoritative) is set and active, the resolver returns it WITHOUT any Feishu round-trip.""" sf_hrbp = await _make_person( db_session, work_email="sf-hrbp@x.com", display_name="SF HRBP", ) leaver = await _make_person( db_session, work_email="leaver@x.com", hris_hrbp_person_id=sf_hrbp.id, ) res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id == sf_hrbp.id assert res.reason is None assert res.hrbp_email == "sf-hrbp@x.com" # No Feishu hops — neither catalog nor user fetch. assert feishu_stub.list_calls == 0 assert feishu_stub.get_user_calls == 0 @pytest.mark.asyncio async def test_sf_hris_hrbp_inactive_falls_through_to_feishu( db_session, feishu_stub, ): """A stale (inactive) ``hris_hrbp_person_id`` must NOT short-circuit — the resolver falls through to the Feishu walk.""" inactive_sf = await _make_person( db_session, work_email="old-sf@x.com", display_name="Old SF HRBP", employment_status="left", ) live_hrbp = await _make_person( db_session, work_email="live@x.com", display_name="Live HRBP", ) leaver = await _make_person( db_session, work_email="olivia@x.com", hris_hrbp_person_id=inactive_sf.id, ) feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "GENERIC_USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["olivia@x.com"] = "fu-leaver" feishu_stub.users_by_id["fu-leaver"] = { "user_id": "fu-leaver", "email": "olivia@x.com", "custom_attrs": [ {"type": "GENERIC_USER", "id": "C-HRBP", "value": {"generic_user": {"id": "fu-live", "type": 1}}}, ], } feishu_stub.users_by_id["fu-live"] = { "user_id": "fu-live", "email": "live@x.com", } res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id == live_hrbp.id assert feishu_stub.list_calls == 1 # ── failure-reason branches ──────────────────────────────── @pytest.mark.asyncio async def test_no_work_email_fails_fast(db_session, feishu_stub): leaver = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid4().hex[:8]}", work_email="", # empty — sync set placeholder display_name="No Email", employment_status="active", ) db_session.add(leaver) await db_session.flush() res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id is None assert "work_email" in res.reason @pytest.mark.asyncio async def test_no_hrbp_attr_in_tenant(db_session, feishu_stub): leaver = await _make_person(db_session, work_email="leaver@x.com") feishu_stub.custom_attrs = [ # Tenant has custom_attrs but none named HRBP {"id": "C-OTHER", "type": "TEXT", "i18n_name": [{"locale": "zh_cn", "value": "工号"}]}, ] res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id is None assert "no Feishu custom_attr named HRBP" in res.reason @pytest.mark.asyncio async def test_leaver_not_in_feishu(db_session, feishu_stub): leaver = await _make_person(db_session, work_email="ghost@x.com") feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] # emails_to_user_id is empty → resolve raises FEISHU_USER_NOT_FOUND res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id is None assert "leaver Feishu user_id resolve failed" in res.reason @pytest.mark.asyncio async def test_leaver_has_no_hrbp_attr_value(db_session, feishu_stub): leaver = await _make_person(db_session, work_email="leaver@x.com") feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["leaver@x.com"] = "fu-leaver" feishu_stub.users_by_id["fu-leaver"] = { "user_id": "fu-leaver", "email": "leaver@x.com", "custom_attrs": [], # no HRBP set on the leaver } res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id is None assert "no HRBP value" in res.reason @pytest.mark.asyncio async def test_hrbp_email_not_in_helix(db_session, feishu_stub): leaver = await _make_person(db_session, work_email="leaver@x.com") feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["leaver@x.com"] = "fu-leaver" feishu_stub.users_by_id["fu-leaver"] = { "user_id": "fu-leaver", "email": "leaver@x.com", "custom_attrs": [ {"type": "USER", "id": "C-HRBP", "value": {"user": {"user_id": "fu-stranger"}}}, ], } feishu_stub.users_by_id["fu-stranger"] = { "user_id": "fu-stranger", "email": "stranger@external.example", # email not in identity_persons } res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id is None assert "does not match any active Helix person" in res.reason assert res.hrbp_email == "stranger@external.example" @pytest.mark.asyncio async def test_hrbp_matched_but_inactive(db_session, feishu_stub): inactive = await _make_person( db_session, work_email="left-hrbp@x.com", display_name="Left HRBP", employment_status="left", ) leaver = await _make_person(db_session, work_email="leaver@x.com") feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["leaver@x.com"] = "fu-leaver" feishu_stub.users_by_id["fu-leaver"] = { "user_id": "fu-leaver", "email": "leaver@x.com", "custom_attrs": [ {"type": "USER", "id": "C-HRBP", "value": {"user": {"user_id": "fu-old"}}}, ], } feishu_stub.users_by_id["fu-old"] = { "user_id": "fu-old", "email": "left-hrbp@x.com", } res = await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver) assert res.person_id is None assert "not active" in res.reason _ = inactive # keep linters happy @pytest.mark.asyncio async def test_attr_id_lookup_is_cached_per_app(db_session, feishu_stub): """The custom_attrs catalog is fetched at most once per app_id + TTL, even across resolver calls — verify with a second resolver invocation that the catalog is NOT re-listed.""" hrbp = await _make_person(db_session, work_email="hrbp@x.com") leaver_a = await _make_person(db_session, work_email="a@x.com") leaver_b = await _make_person(db_session, work_email="b@x.com") feishu_stub.custom_attrs = [ {"id": "C-HRBP", "type": "USER", "i18n_name": [{"locale": "zh_cn", "value": "HRBP"}]}, ] feishu_stub.emails_to_user_id["a@x.com"] = "fu-a" feishu_stub.emails_to_user_id["b@x.com"] = "fu-b" feishu_stub.users_by_id["fu-a"] = { "user_id": "fu-a", "email": "a@x.com", "custom_attrs": [ {"type": "USER", "id": "C-HRBP", "value": {"user": {"user_id": "fu-h"}}}, ], } feishu_stub.users_by_id["fu-b"] = { "user_id": "fu-b", "email": "b@x.com", "custom_attrs": [ {"type": "USER", "id": "C-HRBP", "value": {"user": {"user_id": "fu-h"}}}, ], } feishu_stub.users_by_id["fu-h"] = { "user_id": "fu-h", "email": "hrbp@x.com", } await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver_a) await feishu_hrbp.resolve_hrbp_for_person(db_session, leaver_b) # Catalog fetched exactly once across the two resolves. assert feishu_stub.list_calls == 1 _ = hrbp