"""把 GitHub handle 回写 SuccessFactors ``cust_Other``。 最重要的一条是第一条:**默认不写**。 与邮箱回写(HR 把 payload 原样给了我们、目标是标准 EC 实体、因此默认开) 不同,这里的 payload 形状本身就是待答问题,而失败模式不对称:对一个 **effective-dated** 的 MDF 对象,不带 ``effectiveStartDate`` 的 upsert 不一定会被拒 —— 它可能建出一条起始日期错误的时间片,也就是往人事系统 的权威记录里**静默写错一行**。所以「拿到写权限」不足以开始写,必须有人 读过 HR 的答复、再显式打开。 """ from __future__ import annotations import uuid import httpx import pytest import respx from app.identity import hris_client_routing as routing from app.identity import hris_github_writeback as wb from app.identity.hris_github_writeback import write_github_id_to_hris from app.identity.person_models import IdentityPerson from app.identity.successfactors_client import SuccessFactorsClient from app.integrations import _url_safety _SF_BASE = "https://sf.example/odata/v2" @pytest.fixture(autouse=True) def _public_sf_test_dns(monkeypatch): """被固定的 transport 必须能解析只存在于 respx 里的 ``sf.example``。""" monkeypatch.setattr( _url_safety.socket, "getaddrinfo", lambda *_a, **_k: [(2, 1, 6, "", ("1.1.1.1", 443))], ) class _NoopAuth: async def apply(self, request): # pragma: no cover - trivial return request async def headers(self): # pragma: no cover - trivial return {} def _person(employee_no: str | None = "00000939") -> IdentityPerson: return IdentityPerson( id=uuid.uuid4(), work_email="wei.chen@pingcap.com", display_name="Wei Chen", employment_status="active", source_system="successfactors", hris_person_id="sf-123", employee_no=employee_no, ) def _patch_setting(monkeypatch, value) -> None: async def _fake_get(key, default=None, *, session=None): return value if key == wb._SETTING_KEY else default monkeypatch.setattr(wb, "get_platform_setting", _fake_get) def _patch_client(monkeypatch) -> None: client = SuccessFactorsClient(base_url=_SF_BASE, auth=_NoopAuth()) async def _fake_from_config(cls, *, session=None, timeout=30.0): return client monkeypatch.setattr( routing.SuccessFactorsClient, "from_config", classmethod(_fake_from_config), ) def _mock_upsert(records: list[dict]): return respx.post(f"{_SF_BASE}/upsert").mock( return_value=httpx.Response(200, json={"d": records}), ) # ── 默认不写 ────────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_no_setting_means_no_write_at_all(monkeypatch) -> None: """🔴 没有配置 = 没人回答过 HR 的问题 = 一个字节都不许发出去。 断言的不是返回值 —— 是**没有发生过 HTTP 调用**。只断言 ``skipped`` 会让「开关判断写反了但恰好 SF 也拒了」这种情况看起来是绿的。 """ _patch_setting(monkeypatch, {}) _patch_client(monkeypatch) route = _mock_upsert([{"status": "OK"}]) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "skipped" assert "not enabled" in out["reason"] assert route.call_count == 0 @pytest.mark.asyncio @respx.mock async def test_explicit_false_also_skips(monkeypatch) -> None: _patch_setting(monkeypatch, {"enabled": False}) _patch_client(monkeypatch) route = _mock_upsert([{"status": "OK"}]) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "skipped" assert route.call_count == 0 @pytest.mark.asyncio @respx.mock async def test_a_truthy_non_true_value_does_not_enable_it(monkeypatch) -> None: """``"true"`` / ``1`` 是配置被填错的样子,不是「打开」的意思。 这道闸的意义就是「必须有人明确打开过」,所以它只认布尔真值 —— 一个 手滑填成字符串的配置不该让生产 HRIS 开始被写。 """ for bad in ("true", 1, "yes"): _patch_setting(monkeypatch, {"enabled": bad}) _patch_client(monkeypatch) route = _mock_upsert([{"status": "OK"}]) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "skipped", bad assert route.call_count == 0, bad # ── 打开之后 ────────────────────────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_enabled_upserts_cust_other_keyed_by_employee_no( monkeypatch, ) -> None: _patch_setting(monkeypatch, {"enabled": True}) _patch_client(monkeypatch) route = _mock_upsert([{"status": "OK", "httpCode": 200}]) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "ok" assert route.call_count == 1 body = respx.calls.last.request.content.decode() import json as _json payload = _json.loads(body) assert payload == [{ "__metadata": {"uri": "cust_Other"}, "externalCode": "00000939", "cust_id": "octocat", }] # 键是工号,不是 userId —— cust_Other 的主键与 PerEmail 不同。 assert "personIdExternal" not in body @pytest.mark.asyncio @respx.mock async def test_effective_dated_config_adds_the_start_date(monkeypatch) -> None: """HR 回答「是 effective-dated」之后,答案是一次配置修改,不是改代码。""" _patch_setting(monkeypatch, { "enabled": True, "effective_dated": True, "effective_start_date": "2026-01-01", }) _patch_client(monkeypatch) _mock_upsert([{"status": "OK"}]) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "ok" import json as _json payload = _json.loads(respx.calls.last.request.content.decode()) assert payload[0]["effectiveStartDate"] == "2026-01-01" @pytest.mark.asyncio @respx.mock async def test_extra_required_fields_ride_along(monkeypatch) -> None: _patch_setting(monkeypatch, { "enabled": True, "extra_fields": {"cust_source": "helix"}, }) _patch_client(monkeypatch) _mock_upsert([{"status": "OK"}]) await write_github_id_to_hris(None, person=_person(), github_id="octocat") import json as _json payload = _json.loads(respx.calls.last.request.content.decode()) assert payload[0]["cust_source"] == "helix" @pytest.mark.asyncio @respx.mock async def test_record_level_error_is_failed_not_ok(monkeypatch) -> None: """SF 的 upsert 即使逐条失败也返回 HTTP 200 —— 判据在信封里。""" _patch_setting(monkeypatch, {"enabled": True}) _patch_client(monkeypatch) _mock_upsert([{ "status": "ERROR", "httpCode": 400, "message": "No permission to modify cust_Other", }]) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "failed" assert "No permission" in out["reason"] @pytest.mark.asyncio @respx.mock async def test_an_empty_envelope_is_not_success(monkeypatch) -> None: """SF 一条记录都没回 ⇒ 我们无法宣称写成功了。 ``all()`` 对空序列为真,所以「records 非空」这个条件是载重的:少了它, 一个空信封会被读成 ok,而那正是「以为写进去了、其实没有」。 """ _patch_setting(monkeypatch, {"enabled": True}) _patch_client(monkeypatch) _mock_upsert([]) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "failed" assert "empty" in out["reason"] @pytest.mark.asyncio @respx.mock async def test_http_failure_is_failed_not_raised(monkeypatch) -> None: """契约是「永不抛」—— 一次回写失败不能回滚一个已经被确认的变更。""" _patch_setting(monkeypatch, {"enabled": True}) _patch_client(monkeypatch) respx.post(f"{_SF_BASE}/upsert").mock( return_value=httpx.Response(403, text="Forbidden"), ) out = await write_github_id_to_hris( None, person=_person(), github_id="octocat", ) assert out["status"] == "failed" @pytest.mark.asyncio @respx.mock async def test_no_employee_no_is_skipped_not_failed(monkeypatch) -> None: """没有工号就没有主键。这是「本来就不该写」,不是「写失败了」—— 两者分开,运维看到的待办清单才不会混进不需要处理的行。""" _patch_setting(monkeypatch, {"enabled": True}) _patch_client(monkeypatch) route = _mock_upsert([{"status": "OK"}]) out = await write_github_id_to_hris( None, person=_person(employee_no=None), github_id="octocat", ) assert out["status"] == "skipped" assert route.call_count == 0