"""Annual-leave carryover sync: SF client entity-name fix + service aggregation regression tests. Background — the pre-2026-05-14 code targeted entity ``EmpTimeAccount`` which silently 404s on PingCAP's SF tenant. ``EmpTimeAccountBalance`` exists but rejects ``or`` in ``$filter`` so it can't batch. The fix is :meth:`SuccessFactorsClient.list_time_accounts` (queries ``TimeAccount``) + :meth:`SuccessFactorsClient.list_time_account_details` (queries ``TimeAccountDetail`` and joins on ``TimeAccount_externalCode``); the HR service then aggregates per (user, accountType, fiscal-year role) to derive carry + used. These tests guard: 1. The client points at the correct entity names and aggregates OR-batched ``userId`` / UUID filters (tenant restriction). 2. The HR service correctly picks PRIOR vs CURRENT FY accounts, sums booking lines, and writes the four ``identity_persons.annual_leave_*`` columns. 3. Defensive behavior: SF returning [] (tenant without EC Time-Off) leaves the columns untouched — HR keeps the manual-input path. """ from __future__ import annotations from datetime import UTC, date, datetime from decimal import Decimal from uuid import uuid4 import pytest import pytest_asyncio from app.domains.hr.service import SuccessFactorsLifecycleService from app.identity.person_models import IdentityPerson from app.identity.successfactors_auth import BasicAuth from app.identity.successfactors_client import SuccessFactorsClient def _sf_date(d: date) -> str: """SF's OData v2 epoch-ms representation, exactly as SF returns it.""" ms = int(datetime(d.year, d.month, d.day, tzinfo=UTC).timestamp() * 1000) return f"/Date({ms})/" def _fake_client() -> SuccessFactorsClient: return SuccessFactorsClient( base_url="https://fake.sapsf.cn/odata/v2", auth=BasicAuth("apiuser@MYCO", "pw"), ) # ── Client: entity-name + batching ───────────────────────────────── @pytest.mark.asyncio async def test_list_time_accounts_hits_TimeAccount_entity_with_or_batched_filter( # noqa: N802 — entity name lives in test name monkeypatch, ): """The fix: target ``TimeAccount`` (not the legacy ``EmpTimeAccount`` which 404s) and batch userIds with ``or``.""" captured: list[tuple[str, dict | None]] = [] async def fake_list_entities(self, entity_name, **kwargs): captured.append((entity_name, kwargs)) # Mimic a tenant returning rows for 3 of the 5 users. return [ { "externalCode": "uuid-A-prior", "userId": "P0001", "accountType": "CHN_AnnualLeave", "startDate": _sf_date(date(2025, 4, 1)), "endDate": _sf_date(date(2026, 3, 31)), }, { "externalCode": "uuid-A-current", "userId": "P0001", "accountType": "CHN_AnnualLeave", "startDate": _sf_date(date(2026, 4, 1)), "endDate": _sf_date(date(2027, 3, 31)), }, ] monkeypatch.setattr(SuccessFactorsClient, "_list_entities", fake_list_entities) client = _fake_client() rows = await client.list_time_accounts( user_ids=["P0001", "P0002"], account_types=["CHN_AnnualLeave", "CHN_PL"], ) assert len(captured) == 1, "single chunk expected for 2 users" entity, kwargs = captured[0] assert entity == "TimeAccount", f"expected TimeAccount entity, got {entity}" fc = kwargs["filter_clause"] assert "userId eq 'P0001'" in fc and "userId eq 'P0002'" in fc assert "accountType eq 'CHN_AnnualLeave'" in fc assert "accountType eq 'CHN_PL'" in fc assert len(rows) == 2 @pytest.mark.asyncio async def test_list_time_account_details_or_batches_on_FK(monkeypatch): # noqa: N802 """TimeAccountDetail batches via ``TimeAccount_externalCode eq 'x' or TimeAccount_externalCode eq 'y'`` — the right column, the right join.""" captured: list[dict] = [] async def fake_list_entities(self, entity_name, **kwargs): captured.append({"entity": entity_name, **kwargs}) return [] monkeypatch.setattr(SuccessFactorsClient, "_list_entities", fake_list_entities) client = _fake_client() uuids = [f"uuid-{i}" for i in range(20)] await client.list_time_account_details(time_account_uuids=uuids) # 20 uuids @ 16/chunk → 2 calls assert len(captured) == 2 assert all(c["entity"] == "TimeAccountDetail" for c in captured) assert all("TimeAccount_externalCode eq " in c["filter_clause"] for c in captured) @pytest.mark.asyncio async def test_list_time_accounts_swallows_404_returns_empty(monkeypatch): """Tenant without EC Time-Off → 404. The helper logs + returns [] so the caller can fall back to HR-manual input. We don't want a 404 to abort the whole HR sync.""" async def raising(self, *a, **kw): raise RuntimeError("HTTP 404 NotFoundException") monkeypatch.setattr(SuccessFactorsClient, "_list_entities", raising) client = _fake_client() rows = await client.list_time_accounts( user_ids=["P0001"], account_types=["CHN_AnnualLeave"], ) assert rows == [] @pytest.mark.asyncio async def test_list_time_accounts_empty_user_ids_no_call(monkeypatch): """Empty user_ids → no SF call (would send ``?$filter=()`` SF rejects).""" called = [] async def fake(self, *a, **kw): called.append(1) return [] monkeypatch.setattr(SuccessFactorsClient, "_list_entities", fake) client = _fake_client() assert await client.list_time_accounts(user_ids=[], account_types=["X"]) == [] assert called == [] @pytest.mark.asyncio async def test_list_time_accounts_empty_account_types_pulls_all_types(monkeypatch): """``account_types`` empty / None is the data-driven full-mirror path (leave-domain sync): it MUST still call SF, but with NO ``accountType`` clause so every leave type comes back — only the user clause is sent.""" captured: list[dict] = [] async def fake(self, entity_name, **kw): captured.append(kw) return [] monkeypatch.setattr(SuccessFactorsClient, "_list_entities", fake) client = _fake_client() await client.list_time_accounts(user_ids=["P0001"], account_types=[]) assert len(captured) == 1, "empty account_types must still query SF" fc = captured[0]["filter_clause"] assert "userId eq 'P0001'" in fc assert "accountType" not in fc, "no type clause when pulling all types" # ── Service: aggregation regression ──────────────────────────────── def _make_account(user_id: str, atype: str, start: date, end: date, uuid: str) -> dict: return { "externalCode": uuid, "userId": user_id, "accountType": atype, "startDate": _sf_date(start), "endDate": _sf_date(end), } def _make_detail(uuid: str, booking_type: str, amount: str) -> dict: return { "TimeAccount_externalCode": uuid, "bookingType": booking_type, "bookingAmount": amount, "bookingUnit": "DAYS", } @pytest_asyncio.fixture async def _seeded_person(db_session): """One ``identity_persons`` row that the carryover sync can write to.""" user_id = uuid4() person = IdentityPerson( id=user_id, work_email=f"alt-{user_id}@test.com", display_name="Sync Subject", employment_status="active", source_system="successfactors", hris_person_id="P0001", ) db_session.add(person) await db_session.flush() await db_session.commit() return person @pytest.mark.asyncio async def test_sync_writes_carry_from_prior_fy_and_used_from_current_fy( db_session, _seeded_person, monkeypatch, ): """End-to-end: prior-FY balance flows to ``carry_*`` columns, current-FY ``EMPLOYEE_TIME`` deductions flow to ``used_*``. Matches the live PingCAP data shape from the 2026-05-14 probe. """ person = _seeded_person today = date(2026, 5, 14) async def fake_list_time_accounts(self, *, user_ids, account_types): # Two AnnualLeave accounts (prior + current FY) and one PL # current-FY account; mirrors what live SF returns for P0989. return [ _make_account("P0001", "CHN_AnnualLeave", date(2025, 4, 1), date(2026, 3, 31), "uuid-legal-prior"), _make_account("P0001", "CHN_AnnualLeave", date(2026, 4, 1), date(2027, 3, 31), "uuid-legal-current"), _make_account("P0001", "CHN_PL", date(2026, 4, 1), date(2027, 3, 31), "uuid-welfare-current"), ] async def fake_list_time_account_details(self, *, time_account_uuids): return [ # Prior FY legal: 10 days accrued + 2 days taken → balance 8 _make_detail("uuid-legal-prior", "ACCRUAL", "10"), _make_detail("uuid-legal-prior", "EMPLOYEE_TIME", "-2"), # Current FY legal: 10 accrued, 3 used so far this year _make_detail("uuid-legal-current", "ACCRUAL", "10"), _make_detail("uuid-legal-current", "EMPLOYEE_TIME", "-1"), _make_detail("uuid-legal-current", "DEDUCTION", "-2"), # Current FY welfare: 1.5 entitlement, 0.5 used _make_detail("uuid-welfare-current", "ACCRUAL", "1.5"), _make_detail("uuid-welfare-current", "EMPLOYEE_TIME", "-0.5"), ] fake_client = _fake_client() monkeypatch.setattr( SuccessFactorsClient, "list_time_accounts", fake_list_time_accounts, ) monkeypatch.setattr( SuccessFactorsClient, "list_time_account_details", fake_list_time_account_details, ) service = SuccessFactorsLifecycleService( db_session, sf_client=fake_client, today=today, ) touched = await service.sync_annual_leave_carryover(user_ids=["P0001"]) assert touched == 1, "expected exactly one identity_persons row updated" await db_session.refresh(person) # carry_legal = prior FY balance = 10 - 2 = 8 assert person.annual_leave_carry_legal_days == Decimal("8.0") # carry_welfare: no prior-FY PL account → stays None (HR fills manually) assert person.annual_leave_carry_welfare_days is None # used_legal = sum(abs(EMPLOYEE_TIME + DEDUCTION)) on current-FY legal # = 1 + 2 = 3 assert person.annual_leave_used_legal_days == Decimal("3.0") # used_welfare = current-FY welfare deductions = 0.5 assert person.annual_leave_used_welfare_days == Decimal("0.5") @pytest.mark.asyncio async def test_sync_no_op_when_sf_returns_no_accounts( db_session, _seeded_person, monkeypatch, ): """Tenant without EC Time-Off, or no matching accountTypes for this user → helpers return []. Columns must stay None (HR manual input is the documented fallback).""" person = _seeded_person async def fake_empty(self, **kwargs): return [] monkeypatch.setattr(SuccessFactorsClient, "list_time_accounts", fake_empty) monkeypatch.setattr(SuccessFactorsClient, "list_time_account_details", fake_empty) service = SuccessFactorsLifecycleService( db_session, sf_client=_fake_client(), today=date(2026, 5, 14), ) touched = await service.sync_annual_leave_carryover(user_ids=["P0001"]) assert touched == 0 await db_session.refresh(person) assert person.annual_leave_carry_legal_days is None assert person.annual_leave_used_legal_days is None @pytest.mark.asyncio async def test_sync_only_touches_persons_with_matching_hris_person_id( db_session, _seeded_person, monkeypatch, ): """A non-targeted person row must stay untouched even if SF returns a different userId — we must NOT write to anyone except those whose ``hris_person_id`` is in the input list.""" # Add a second person with a different hris_person_id. person_other = IdentityPerson( id=uuid4(), work_email="other-sync@test.com", display_name="Other", employment_status="active", source_system="successfactors", hris_person_id="P0002", ) db_session.add(person_other) await db_session.flush() await db_session.commit() async def fake_accounts(self, *, user_ids, account_types): return [ _make_account("P0001", "CHN_AnnualLeave", date(2025, 4, 1), date(2026, 3, 31), "uuid-x"), ] async def fake_details(self, *, time_account_uuids): return [_make_detail("uuid-x", "ACCRUAL", "5")] monkeypatch.setattr(SuccessFactorsClient, "list_time_accounts", fake_accounts) monkeypatch.setattr(SuccessFactorsClient, "list_time_account_details", fake_details) service = SuccessFactorsLifecycleService( db_session, sf_client=_fake_client(), today=date(2026, 5, 14), ) await service.sync_annual_leave_carryover(user_ids=["P0001"]) await db_session.refresh(person_other) assert person_other.annual_leave_carry_legal_days is None, ( "P0002 must not have been touched by a sync targeting P0001" ) @pytest.mark.asyncio async def test_sync_idempotent_same_values_no_rewrite( db_session, _seeded_person, monkeypatch, ): """Re-running with identical SF data should not bump ``rows_touched`` — the per-column equality check guards against UPDATE storms from a daily cron firing against drift-free rows.""" _ = _seeded_person # ensure the fixture row is created in the DB async def fake_accounts(self, *, user_ids, account_types): return [ _make_account("P0001", "CHN_AnnualLeave", date(2025, 4, 1), date(2026, 3, 31), "uuid-prior"), ] async def fake_details(self, *, time_account_uuids): return [_make_detail("uuid-prior", "ACCRUAL", "5")] monkeypatch.setattr(SuccessFactorsClient, "list_time_accounts", fake_accounts) monkeypatch.setattr(SuccessFactorsClient, "list_time_account_details", fake_details) svc = SuccessFactorsLifecycleService( db_session, sf_client=_fake_client(), today=date(2026, 5, 14), ) first = await svc.sync_annual_leave_carryover(user_ids=["P0001"]) assert first == 1 second = await svc.sync_annual_leave_carryover(user_ids=["P0001"]) assert second == 0, "rerun with no drift must report 0 rows touched"