"""Unit tests for the SuccessFactors OData client HTTP primitives. Covers the two regressions found 2026-05-23: 1. ``list_current_jobs`` previously selected ``positionNameEN``/ ``positionName`` / ``jobTitle`` — none of which are provisioned on PingCAP's SF tenant. OData rejects the whole query with HTTP 400 (``Property '' is invalid``), so ``hris.absent_person_check`` silently failed daily for 10 days. The regression tests pin the select-field set down to "what the working ``get_jobs_for_users`` / incremental path also requests." 2. ``_get_json`` previously called ``response.raise_for_status()`` which produces ``"Client error '400 Bad Request' for url ''"`` and discards the OData error body. That body is the only place the actual reason lives. The new ``SuccessFactorsHTTPError`` carries the parsed ``error.code`` + ``error.message.value`` so future failures are diagnosable from log lines alone. """ from __future__ import annotations from unittest.mock import AsyncMock import httpx import pytest import respx from app.core.pagination import PaginationGuardError from app.identity.successfactors_client import ( SuccessFactorsClient, SuccessFactorsHTTPError, _extract_odata_error, ) from app.integrations import _url_safety @pytest.fixture(autouse=True) def _public_test_dns(monkeypatch): monkeypatch.setattr( _url_safety.socket, "getaddrinfo", lambda *_args, **_kwargs: [(2, 1, 6, "", ("1.1.1.1", 443))], ) class _NoopAuth: """SuccessFactorsAuth stub — leaves the request unmodified. The HTTP primitives don't care what the bearer is, so we skip the full OAuth dance and let respx assert on the request as-built. """ async def apply(self, request: httpx.Request) -> None: # noqa: D401 return None def describe(self) -> dict[str, str]: return {"kind": "noop"} @pytest.mark.asyncio async def test_list_entities_preserves_page_order_and_terminates(monkeypatch) -> None: client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) pages = iter([ {"d": {"results": [{"id": 1}], "__next": "page-A"}}, {"d": {"results": [{"id": 2}], "__next": "page-B"}}, {"d": {"results": [{"id": 3}]}}, ]) async def fake_get(*_args, **_kwargs): return next(pages) monkeypatch.setattr(client, "_get_json", fake_get) rows = await client._list_entities("User", select_fields=["id"]) assert [row["id"] for row in rows] == [1, 2, 3] @pytest.mark.asyncio async def test_list_entities_rejects_alternating_next_url(monkeypatch) -> None: client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) calls = 0 async def fake_get(path, **_kwargs): nonlocal calls calls += 1 next_url = "secret-page-A" if path == "secret-page-B" else "secret-page-B" return {"d": {"results": [{"id": calls}], "__next": next_url}} monkeypatch.setattr(client, "_get_json", fake_get) with pytest.raises(PaginationGuardError) as exc_info: await client._list_entities("User", select_fields=["id"]) assert calls == 3 assert "secret-page" not in str(exc_info.value) @pytest.mark.asyncio async def test_list_entities_rejects_oversized_page(monkeypatch) -> None: client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) monkeypatch.setattr( "app.identity.successfactors_client._MAX_PAGINATION_ITEMS", 1, ) get_json = AsyncMock(return_value={ "d": {"results": [{"id": 1}, {"id": 2}]}, }) monkeypatch.setattr(client, "_get_json", get_json) with pytest.raises(PaginationGuardError, match="max_items_exceeded"): await client._list_entities("User", select_fields=["id"]) get_json.assert_awaited_once() @pytest.mark.asyncio @pytest.mark.parametrize("payload", [ {}, {"d": {}}, {"d": {"results": {"not": "a-list"}}}, {"d": {"results": [], "__next": {"not": "opaque"}}}, ]) async def test_list_entities_rejects_malformed_odata_page( monkeypatch, payload, ) -> None: client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) monkeypatch.setattr(client, "_get_json", AsyncMock(return_value=payload)) with pytest.raises(PaginationGuardError, match="malformed_response"): await client._list_entities("User", select_fields=["id"]) # ── list_current_jobs select-field pin ─────────────────────────────── _DISALLOWED_EMPJOB_FIELDS = frozenset({ # Custom-named position fields that don't exist on PingCAP's SF # tenant. Putting either back breaks the daily absent-person check # — see the docstring at the top of this file. "positionName", "positionNameEN", # ``jobTitle`` was added in the same regression even though it IS a # standard EmpJob property. We exclude it preventively because the # downstream consumer already falls back to the Position cache; if # someone proves it works on this tenant they can put it back. "jobTitle", }) @pytest.mark.asyncio @respx.mock async def test_list_current_jobs_does_not_select_disallowed_fields() -> None: """The OData ``$select`` MUST NOT include the fields that historically caused 400s on PingCAP's SF tenant. This test is the regression fence.""" captured: dict[str, str] = {} def _capture(request: httpx.Request) -> httpx.Response: captured["select"] = request.url.params.get("$select", "") captured["filter"] = request.url.params.get("$filter", "") return httpx.Response( 200, json={"d": {"results": []}}, ) respx.get("https://sf.example/odata/v2/EmpJob").mock(side_effect=_capture) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) await client.list_current_jobs(limit=None) requested = set(captured["select"].split(",")) leaked = requested & _DISALLOWED_EMPJOB_FIELDS assert not leaked, ( f"list_current_jobs requested disallowed EmpJob field(s) " f"{sorted(leaked)} — these are not provisioned on PingCAP's " f"SF tenant and will 400 the entire query. See " f"test_successfactors_client.py header." ) # The filter must still pin to ``effectiveLatestChange eq true`` — # otherwise we'd surface stale job rows. assert captured["filter"] == "effectiveLatestChange eq true" @pytest.mark.asyncio @respx.mock async def test_list_current_jobs_select_matches_get_jobs_for_users() -> None: """``list_current_jobs`` and ``get_jobs_for_users`` should request the same EmpJob attribute set (minus the incremental-only ``event``/``eventReason``/``lastModifiedDateTime``/``seqNumber`` columns that the full-roster path doesn't need). Drift between these two opens the door to "works incrementally, 400s on full sync" bug shape that we just fixed.""" captured: dict[str, set[str]] = {} def _record(request: httpx.Request) -> httpx.Response: # ``$filter`` disambiguates which method is calling: the # full-roster path pins to ``effectiveLatestChange eq true``; # the chunked path uses ``userId eq 'u1'``. flt = request.url.params.get("$filter", "") key = "current" if "effectiveLatestChange" in flt else "chunk" captured[key] = set( request.url.params.get("$select", "").split(",") ) return httpx.Response(200, json={"d": {"results": []}}) respx.get("https://sf.example/odata/v2/EmpJob").mock(side_effect=_record) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) await client.list_current_jobs(limit=None) await client.get_jobs_for_users(["u1"]) assert "current" in captured and "chunk" in captured, ( "Both methods must have been recorded — check the respx mock" ) full_only = captured["current"] - captured["chunk"] chunk_only = captured["chunk"] - captured["current"] # The only field full-roster needs that the chunked path doesn't is # ``effectiveLatestChange`` — it lives in the ``$filter`` clause and # has to be in ``$select`` too on some SF tenants for the # roundtrip. Anything else in full-only is suspicious — that's # exactly how the ``positionNameEN`` regression slipped in. allowed_full_only = {"effectiveLatestChange"} unexpected_full_only = full_only - allowed_full_only assert not unexpected_full_only, ( f"list_current_jobs requests EmpJob fields not in " f"get_jobs_for_users: {sorted(unexpected_full_only)}. If you " f"intentionally need a new field, add it to BOTH paths in the " f"same change and update allowed_full_only in this test." ) # The reverse direction is expected to be non-empty # (event/eventReason/seqNumber are incremental-only) — assert only # to document the intentional asymmetry. assert "event" in chunk_only and "eventReason" in chunk_only # ── _get_json error-body capture ───────────────────────────────────── @pytest.mark.asyncio @respx.mock async def test_get_json_raises_typed_error_with_odata_message() -> None: """OData v2 error envelope → ``SuccessFactorsHTTPError`` carrying code + message.value.""" respx.get("https://sf.example/odata/v2/EmpJob").mock( return_value=httpx.Response( 400, json={ "error": { "code": "COE_GENERAL_SERVER_FAILURE", "message": { "lang": "en-US", "value": "Property 'positionNameEN' is invalid.", }, } }, ) ) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) with pytest.raises(SuccessFactorsHTTPError) as exc: await client.list_current_jobs(limit=None) assert exc.value.status_code == 400 assert "positionNameEN" in exc.value.detail assert "COE_GENERAL_SERVER_FAILURE" in exc.value.detail # The full str() goes to logs; both the status and the URL should # be present so failure_notifier alerts are actionable. Match the # path component specifically so a future refactor that moves # ``"EmpJob"`` out of the URL into e.g. a request-id header still # fails this test rather than passing on accident. msg = str(exc.value) assert "400" in msg assert exc.value.url.endswith("/EmpJob") # The query string MUST NOT survive into the exception URL — it # can echo OR-batched userId lists on get_jobs_for_users (LOW-sev # PII leak into the admin-readable scheduled_job_runs.error # column). Path alone is enough to identify the failing entity; # the structured ``detail`` already names the failing property. assert "$select" not in exc.value.url and "$filter" not in exc.value.url assert exc.value.url == "https://sf.example/odata/v2/EmpJob" @pytest.mark.asyncio @respx.mock async def test_get_json_falls_back_to_raw_body_when_non_odata() -> None: """Upstream proxy returning HTML / plain text (gateway, WAF) is still surfaced with the body snippet — capped to 500 chars to avoid log bloat.""" huge_body = "" + ("ERR" * 1000) + "" respx.get("https://sf.example/odata/v2/FOCompany").mock( return_value=httpx.Response( 502, text=huge_body, headers={"content-type": "text/html"}, ) ) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) with pytest.raises(SuccessFactorsHTTPError) as exc: await client.ping() assert exc.value.status_code == 502 # Detail is non-empty and truncated. assert exc.value.detail assert len(exc.value.detail) <= 500 assert "ERR" in exc.value.detail # body content surfaced def test_extract_odata_error_handles_empty_body() -> None: """No body / empty body / parse failure — return a placeholder so the error message is never just a bare colon.""" empty = httpx.Response(500, text="") assert _extract_odata_error(empty) == "(empty body)" def test_extract_odata_error_handles_string_message() -> None: """Some OData implementations emit ``message`` as a flat string rather than ``{lang, value}``. We accept both shapes.""" resp = httpx.Response( 400, json={"error": {"code": "BAD", "message": "Field invalid"}}, ) extracted = _extract_odata_error(resp) assert "BAD" in extracted assert "Field invalid" in extracted def test_extract_odata_error_handles_undecodable_body() -> None: """Non-UTF-8 bytes declared as UTF-8 (broken upstream proxy) must fall back to a placeholder rather than crashing the ``_get_json`` error path. Logic-scan flagged ``response.text`` can raise ``UnicodeDecodeError``.""" # 0x80 is invalid as a UTF-8 start byte. resp = httpx.Response( 500, content=b"\x80\x81\x82", headers={"content-type": "text/plain; charset=utf-8"}, ) extracted = _extract_odata_error(resp) # Either a placeholder OR a degraded text representation — but # never an unhandled exception bubbling up. assert extracted # ── transient-transport retry (prod 2026-06-04 absent_person_check) ─── @pytest.mark.asyncio @respx.mock async def test_get_json_retries_transient_read_timeout(monkeypatch) -> None: """A single ``httpx.ReadTimeout`` on a page GET must be retried, not fail the whole call. Mirrors the prod 2026-06-04 incident where the CN tenant's full ``EmpJob`` roster pull hit one slow page and the nightly ``hris.absent_person_check`` failed + paged every admin even though the upstream recovered on the next tick.""" # No real sleeping in the retry backoff. async def _no_sleep(_seconds: float) -> None: return None monkeypatch.setattr( "app.identity.successfactors_client.asyncio.sleep", _no_sleep, ) calls = {"n": 0} def _flaky(request: httpx.Request) -> httpx.Response: calls["n"] += 1 if calls["n"] == 1: raise httpx.ReadTimeout("simulated slow page", request=request) return httpx.Response(200, json={"d": {"results": [{"userId": "u1"}]}}) respx.get("https://sf.example/odata/v2/EmpJob").mock(side_effect=_flaky) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) jobs = await client.list_current_jobs(limit=None) assert calls["n"] == 2, "expected one retry after the transient timeout" assert [j["userId"] for j in jobs] == ["u1"] @pytest.mark.asyncio @respx.mock async def test_get_json_raises_after_exhausting_transport_retries( monkeypatch, ) -> None: """A persistently-timing-out endpoint must exhaust the bounded retry budget and re-raise the original ``httpx.ReadTimeout`` (NOT an empty string, NOT a swallowed None) so the handler's ``format_exception_for_log`` surfaces the real class to the failure-notifier.""" async def _no_sleep(_seconds: float) -> None: return None monkeypatch.setattr( "app.identity.successfactors_client.asyncio.sleep", _no_sleep, ) calls = {"n": 0} def _always_timeout(request: httpx.Request) -> httpx.Response: calls["n"] += 1 raise httpx.ReadTimeout("always slow", request=request) respx.get("https://sf.example/odata/v2/EmpJob").mock( side_effect=_always_timeout, ) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) with pytest.raises(httpx.ReadTimeout): await client.list_current_jobs(limit=None) assert calls["n"] == 3, "expected original + 2 retries before raising" # ── effective_as_of (future-hire enrichment) ───────────────────────── @pytest.mark.asyncio @respx.mock async def test_get_jobs_for_users_effective_as_of_adds_date_window() -> None: """``effective_as_of`` pins the EmpJob read to the slice effective on the hire date via SF EC ``fromDate``/``toDate`` — so a pre-start hire's future-dated department / managerId can be pulled at the 24h-lead build. Without it, the query carries no date params (default as-of-today).""" import datetime captured: dict[str, dict[str, str]] = {} def _capture(request: httpx.Request) -> httpx.Response: p = request.url.params key = "asof" if p.get("fromDate") else "plain" captured[key] = { "fromDate": p.get("fromDate", ""), "toDate": p.get("toDate", ""), "filter": p.get("$filter", ""), } return httpx.Response(200, json={"d": {"results": []}}) respx.get("https://sf.example/odata/v2/EmpJob").mock(side_effect=_capture) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) await client.get_jobs_for_users(["u1"]) await client.get_jobs_for_users( ["u1"], effective_as_of=datetime.date(2026, 7, 20), ) assert "plain" in captured and captured["plain"]["fromDate"] == "", ( "default call must not carry effective-dating params" ) assert captured["asof"]["fromDate"] == "2026-07-20" assert captured["asof"]["toDate"] == "2026-07-20" # userId scoping still present alongside the date window. assert "userId" in captured["asof"]["filter"] @pytest.mark.asyncio @respx.mock async def test_list_job_relationships_effective_as_of_adds_date_window() -> None: """The same date-window pin for EmpJobRelationships, so a future hire's future-dated 2448 (HRBP) / 2450 (HR Ops) rows resolve at build time and the welcome email can CC them.""" import datetime captured: dict[str, dict[str, str]] = {} def _capture(request: httpx.Request) -> httpx.Response: p = request.url.params key = "asof" if p.get("fromDate") else "plain" captured[key] = { "fromDate": p.get("fromDate", ""), "toDate": p.get("toDate", ""), } return httpx.Response(200, json={"d": {"results": []}}) respx.get( "https://sf.example/odata/v2/EmpJobRelationships" ).mock(side_effect=_capture) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) await client.list_job_relationships(["2448", "2450"], user_ids=["u1"]) await client.list_job_relationships( ["2448", "2450"], user_ids=["u1"], effective_as_of=datetime.date(2026, 7, 20), ) assert captured["plain"]["fromDate"] == "" assert captured["asof"]["fromDate"] == "2026-07-20" assert captured["asof"]["toDate"] == "2026-07-20" # ── custom07 (HR-assigned corporate 邮箱名) select pin ──────────────── @pytest.mark.asyncio @respx.mock async def test_get_users_selects_custom07_email_name() -> None: """``get_users`` MUST request ``custom07`` — the HR-assigned corporate 邮箱名 (e.g. "john.chen"). It is the TOP-priority email source at reserve time; if the ``$select`` drops it, every hire silently falls back to the given.family pinyin derivation and ignores HR's chosen mailbox. Verified live 2026-07-20 (P10019 custom07="john.chen", matching the card).""" captured: dict[str, set[str]] = {} def _capture(request: httpx.Request) -> httpx.Response: captured["select"] = set( request.url.params.get("$select", "").split(",") ) return httpx.Response(200, json={"d": {"results": []}}) respx.get("https://sf.example/odata/v2/User").mock(side_effect=_capture) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) await client.get_users(["P10019"]) assert "custom07" in captured["select"], ( "get_users must $select custom07 (HR-assigned 邮箱名) so the desired " "mailbox name reaches reserve_work_email_for." ) # ── PerPersonal.customString4 (authoritative 邮箱名 source) ──────────── @pytest.mark.asyncio @respx.mock async def test_get_personal_email_names_reads_custom_string4() -> None: """``get_personal_email_names`` fetches ``PerPersonal.customString4`` (SF's AUTHORITATIVE HR-assigned 邮箱名 — matches the outbound ``emailName <<-- customString4`` mapping) keyed by personIdExternal, filtered to the requested ids, returning only non-blank stripped values.""" captured: dict[str, object] = {} def _capture(request: httpx.Request) -> httpx.Response: captured["select"] = set( request.url.params.get("$select", "").split(",") ) captured["filter"] = request.url.params.get("$filter", "") return httpx.Response( 200, json={ "d": { "results": [ {"personIdExternal": "P10019", "customString4": " san.zhang "}, {"personIdExternal": "P10022", "customString4": ""}, {"personIdExternal": "P10023", "customString4": None}, ] } }, ) respx.get("https://sf.example/odata/v2/PerPersonal").mock(side_effect=_capture) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) result = await client.get_personal_email_names(["P10019", "P10022", "P10023"]) assert "customString4" in captured["select"] assert "personIdExternal eq 'P10019'" in captured["filter"] # Blank / None values are dropped; the real one is stripped. assert result == {"P10019": "san.zhang"} @pytest.mark.asyncio async def test_get_personal_email_names_empty_input_no_call() -> None: """Empty id list ⇒ no SF round-trip, empty map.""" client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) assert await client.get_personal_email_names([]) == {} # ── get_termination_reasons_for_users (2026-07-30) ─────────────────────── @pytest.mark.asyncio @respx.mock async def test_get_termination_reasons_resolves_external_code() -> None: """The picklist field ``customString85`` stores the OPTION ID (``2567``), not the externalCode (``D04``) HR quotes — the method must $expand ``customString85Nav`` and return the externalCode. Verified live on both prod tenants 2026-07-30.""" captured: dict[str, str] = {} def _capture(request: httpx.Request) -> httpx.Response: captured["select"] = request.url.params.get("$select", "") captured["expand"] = request.url.params.get("$expand", "") captured["filter"] = request.url.params.get("$filter", "") return httpx.Response(200, json={"d": {"results": [ { "userId": "100112", "customString85": "2567", "customString85Nav": {"externalCode": "D04"}, "lastModifiedDateTime": "/Date(1753833600000)/", }, { "userId": "P01191", "customString85": "2565", "customString85Nav": {"externalCode": "D02"}, "lastModifiedDateTime": "/Date(1753833600000)/", }, ]}}) respx.get( "https://sf.example/odata/v2/EmpEmploymentTermination" ).mock(side_effect=_capture) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) result = await client.get_termination_reasons_for_users( ["100112", "P01191"] ) assert result == {"100112": "D04", "P01191": "D02"} assert captured["expand"] == "customString85Nav" assert "userId eq '100112'" in captured["filter"] @pytest.mark.asyncio @respx.mock async def test_get_termination_reasons_multi_row_latest_wins() -> None: """Rehire history can leave several termination rows per userId — the latest ``lastModifiedDateTime`` must win regardless of order.""" respx.get( "https://sf.example/odata/v2/EmpEmploymentTermination" ).mock(return_value=httpx.Response(200, json={"d": {"results": [ { "userId": "P01", "customString85Nav": {"externalCode": "D07"}, "lastModifiedDateTime": "/Date(1753833600000)/", # newer }, { "userId": "P01", "customString85Nav": {"externalCode": "D01"}, "lastModifiedDateTime": "/Date(1600000000000)/", # stale stint }, ]}})) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) assert await client.get_termination_reasons_for_users(["P01"]) == { "P01": "D07", } @pytest.mark.asyncio @respx.mock async def test_get_termination_reasons_degrades_to_raw_option_id() -> None: """A tenant that rejects the $expand (COE_PROPERTY_NOT_FOUND-style 400) must degrade to the raw option id rather than fail — the exclusion config may pin option ids as belt-and-suspenders.""" calls = {"n": 0} def _flaky(request: httpx.Request) -> httpx.Response: calls["n"] += 1 if request.url.params.get("$expand"): return httpx.Response(400, json={"error": { "code": "COE0021", "message": {"value": "Invalid property names"}, }}) return httpx.Response(200, json={"d": {"results": [ {"userId": "P02", "customString85": "2566"}, ]}}) respx.get( "https://sf.example/odata/v2/EmpEmploymentTermination" ).mock(side_effect=_flaky) client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) assert await client.get_termination_reasons_for_users(["P02"]) == { "P02": "2566", } assert calls["n"] == 3 # two expand shapes rejected, raw shape served @pytest.mark.asyncio async def test_get_termination_reasons_empty_input_no_call() -> None: client = SuccessFactorsClient( base_url="https://sf.example/odata/v2", auth=_NoopAuth(), ) assert await client.get_termination_reasons_for_users([]) == {}