"""HR admin endpoints: test-connection + full-employee sync. Use monkeypatch to stub the ``SuccessFactorsClient`` factory and its HTTP methods so these tests don't hit a real SF tenant. """ from __future__ import annotations import pytest from sqlalchemy import select from app.audit.models import AuditLog from app.identity.person_models import IdentityExternalIdentity, IdentityPerson # Autouse fixture: stop the SF apply path from hitting fake.sapsf.cn for # master-data (each list call otherwise burns the 30s httpx timeout). The # fake client below only stubs users/jobs/employments, not the ~11 # master-data list endpoints _ensure_master_data_loaded fans out to. from tests.test_hr._sf_no_network import ( # noqa: F401 sf_no_network_master_data, ) def _install_fake_client(monkeypatch, *, ping_result=None, ping_error=None, active_users=None): from app.identity.successfactors_auth import BasicAuth from app.identity.successfactors_client import SuccessFactorsClient async def _fake_from_config(cls, **_kwargs): return SuccessFactorsClient( base_url="https://fake.sapsf.cn/odata/v2", auth=BasicAuth("apiuser@MYCO", "pw"), # Fail-fast backstop: if any unstubbed SF call ever leaks past # the test fakes again, it should error in ~2s, not hang on # the prod 30s timeout (prod still uses the 30s default). timeout=2.0, ) async def _fake_ping(self): if ping_error is not None: raise ping_error return ping_result or { "ok": True, "base_url": self.base_url, "auth": self.auth.describe(), "sample_company": "Acme", } async def _fake_list_active_users(self): # Backward-compat for tests that still call this method directly # (only forfeited users get filtered; this fake's ``active_users`` # list is treated as already-active-only — mirrors the historical # contract from before the wangdi PR). return [u for u in (active_users or []) if u.get("status", "t") == "t"] async def _fake_list_all_users(self): # Bootstrap full sync (post-wangdi-PR) now reads via # ``list_all_users``; return the fixture as-is so a test passing # mixed status t/f users actually exercises both onboard and # offboard routing. return active_users or [] async def _fake_get_jobs_for_users(self, user_ids): return [] async def _fake_get_employments_for_users(self, user_ids): # Default empty — tests exercising the forfeited path can # monkeypatch this method themselves with the right endDate # payload. Returning empty is safe: ``preview_full_employee_sync`` # falls back to ``user.lastModifiedDateTime`` for endDate when # EmpEmployment carries no row. return [] monkeypatch.setattr( SuccessFactorsClient, "from_config", classmethod(_fake_from_config), ) monkeypatch.setattr(SuccessFactorsClient, "ping", _fake_ping) monkeypatch.setattr(SuccessFactorsClient, "list_active_users", _fake_list_active_users) monkeypatch.setattr(SuccessFactorsClient, "list_all_users", _fake_list_all_users) monkeypatch.setattr( SuccessFactorsClient, "get_jobs_for_users", _fake_get_jobs_for_users, ) monkeypatch.setattr( SuccessFactorsClient, "get_employments_for_users", _fake_get_employments_for_users, ) def _admin_headers(admin_user): _, token = admin_user return {"Authorization": f"Bearer {token}"} @pytest.mark.asyncio async def test_test_connection_success( client, admin_user, db_session, monkeypatch, ): _install_fake_client( monkeypatch, ping_result={ "ok": True, "base_url": "https://fake.sapsf.cn/odata/v2", "auth": {"type": "basic", "username": "apiuser@MYCO"}, "sample_company": "Acme Corp", }, ) response = await client.post( "/api/v1/hr/successfactors/test-connection", headers=_admin_headers(admin_user), ) assert response.status_code == 200 body = response.json()["data"] assert body["ok"] is True assert body["sample_company"] == "Acme Corp" assert body["auth"]["type"] == "basic" audit = await db_session.scalar( select(AuditLog).where( AuditLog.action == "hr.successfactors.test_connection", AuditLog.status == "success", ) ) assert audit is not None @pytest.mark.asyncio async def test_test_connection_http_error_returns_payload( client, admin_user, db_session, monkeypatch, ): """An SF-side 4xx → ``ok: false`` payload carrying the parsed OData reason. The client now raises ``SuccessFactorsHTTPError`` rather than bare ``httpx.HTTPStatusError`` so the admin sees the actual message (``"Property 'foo' is invalid"``) instead of just ``"Client error '400 Bad Request' for url '...'"``.""" from app.identity.successfactors_client import SuccessFactorsHTTPError _install_fake_client( monkeypatch, ping_error=SuccessFactorsHTTPError( status_code=401, url="https://fake/odata/v2/FOCompany", detail="invalid_credentials: bearer rejected", ), ) response = await client.post( "/api/v1/hr/successfactors/test-connection", headers=_admin_headers(admin_user), ) # HTTP error surfaces as 200 + ok:false so the frontend can display it. assert response.status_code == 200 body = response.json()["data"] assert body["ok"] is False assert body["http_status"] == 401 # The parsed reason is in the payload so the admin doesn't have to # tail server logs to debug a misconfigured tenant. assert "invalid_credentials" in body["error"] @pytest.mark.asyncio async def test_test_connection_missing_config_returns_503( client, admin_user, monkeypatch, ): from app.core.exceptions import AppException from app.identity.successfactors_client import SuccessFactorsClient async def _missing_from_config(cls, **_kwargs): raise AppException( code="PLATFORM_CONFIG_MISSING", message="SuccessFactors 配置未填写", status_code=503, ) monkeypatch.setattr( SuccessFactorsClient, "from_config", classmethod(_missing_from_config), ) response = await client.post( "/api/v1/hr/successfactors/test-connection", headers=_admin_headers(admin_user), ) assert response.status_code == 503 body = response.json() assert body["errors"][0]["code"] == "PLATFORM_CONFIG_MISSING" @pytest.mark.asyncio async def test_test_connection_unexpected_error_is_not_exposed( client, admin_user, db_session, monkeypatch, caplog, ): secret = "password=hunter2 at /srv/private/config.py:99" _install_fake_client(monkeypatch, ping_error=RuntimeError(secret)) response = await client.post( "/api/v1/hr/successfactors/test-connection", headers=_admin_headers(admin_user), ) assert response.status_code == 200 body = response.json() assert body["data"] == {"ok": False, "error": "CONNECTION_TEST_FAILED"} assert body["meta"]["trace_id"] assert secret not in response.text assert secret not in caplog.text audit = await db_session.scalar( select(AuditLog).where( AuditLog.action == "hr.successfactors.test_connection", AuditLog.status == "failure", ) ) assert audit is not None assert audit.detail["error"] == "CONNECTION_TEST_FAILED" assert audit.detail["error_type"] == "RuntimeError" assert secret not in str(audit.detail) @pytest.mark.asyncio async def test_full_employee_sync_stages_onboard_events( client, admin_user, db_session, monkeypatch, ): _install_fake_client( monkeypatch, active_users=[ { "userId": "P001", "displayName": "Alice", "email": "alice@example.com", "empId": "P001", "status": "t", "hireDate": "/Date(1700000000000)/", "lastModifiedDateTime": "/Date(1700000000000)/", }, { "userId": "P002", "displayName": "Bob", "email": "bob@example.com", "empId": "P002", "status": "t", "hireDate": "/Date(1700000000000)/", "lastModifiedDateTime": "/Date(1700000000000)/", }, ], ) response = await client.post( "/api/v1/hr/sync/full-employees/preview", headers=_admin_headers(admin_user), ) assert response.status_code == 200 data = response.json()["data"] # Fan-out response — one batch per enabled HRIS instance. In this # test there is none configured so fan_out falls back to the legacy # single-instance path and returns exactly one batch. assert "batches" in data, data assert len(data["batches"]) == 1 batch = data["batches"][0] assert batch["status"] == "pending_review" assert batch["events_staged"] == 2 assert len(batch["staged_events"]) == 2 assert {e["event_type"] for e in batch["staged_events"]} == {"onboard"} # Confirm now flips the batch to ``applying`` and dispatches the heavy # per-event apply to a background task (202 — 2026-06-16 async-apply # refactor), so it never blocks the request / times out the frontend. # # Suppress the real background spawn for this test: the spawned task # (``run_batch_apply_in_background``) opens its OWN committed session # and, under the live test event loop, races the explicit # ``_apply_staged_events`` call below — both insert ``P001`` and # collide on ``uq_identity_persons_source`` (flaky duplicate-entry). # We instead drive the apply deterministically on the test session, # exactly as ``test_async_batch_apply_2026_06_16`` does. The 202 + # ``applying`` flip is still exercised end-to-end through the router. from app.domains.hr import router as _hr_router def _no_spawn(coro, *args, **kwargs): # Close the un-awaited coroutine so it doesn't emit a # "coroutine was never awaited" RuntimeWarning. if hasattr(coro, "close"): coro.close() monkeypatch.setattr(_hr_router, "_spawn_background", _no_spawn) confirm = await client.post( f"/api/v1/hr/sync/batches/{batch['id']}/confirm", headers=_admin_headers(admin_user), ) assert confirm.status_code == 202 assert confirm.json()["data"]["status"] == "applying" # Drive the apply deterministically on the test session and verify the # end state: 2 events applied, 2 persons upserted. from uuid import UUID from app.domains.hr.service import SuccessFactorsLifecycleService result = await SuccessFactorsLifecycleService(db_session)._apply_staged_events( UUID(batch["id"]), confirmed_by="test", ) assert result["status"] == "confirmed" assert result["events_confirmed"] == 2 assert result["people_upserted"] == 2 persons = ( await db_session.execute( select(IdentityPerson).where(IdentityPerson.hris_person_id.in_(["P001", "P002"])) ) ).scalars().all() assert len(persons) == 2 assert {p.display_name for p in persons} == {"Alice", "Bob"} ext = ( await db_session.execute( select(IdentityExternalIdentity).where( IdentityExternalIdentity.provider == "successfactors", IdentityExternalIdentity.provider_subject.in_(["P001", "P002"]), ) ) ).scalars().all() assert len(ext) == 2