"""Unit tests for the single-source-of-truth SF lifecycle classifier. ``classify_inactive_sf_user`` is the ONE function both the full-roster and incremental sync paths call to decide whether an SF ``status='f'`` user is a leaver or a not-yet-started hire. Centralizing it is the design fix for the bug class where the two paths re-decided this with drifting logic (Camila P10009 add-order race; Robert Wood P10013 Shanghai-tz `>`-vs-`>=` boundary; the fabricated-``lastModifiedDateTime`` offboard). These are pure, DB-free tests pinning every branch. """ from __future__ import annotations from datetime import date from app.domains.hr.service import ( LIFECYCLE_FUTURE_HIRE, LIFECYCLE_NO_TERMINATION, LIFECYCLE_OFFBOARD, LIFECYCLE_REASON_EXCLUDED, classify_inactive_sf_user, normalize_reason_codes, sf_status_is_active, ) def test_active_status_set_shared(): assert sf_status_is_active("t") assert sf_status_is_active("T") assert sf_status_is_active(" active_external_suite ") assert not sf_status_is_active("f") assert not sf_status_is_active(None) assert not sf_status_is_active("") def test_future_hire_is_never_a_leaver(): """hire_date strictly after as_of → future hire.""" d = classify_inactive_sf_user( hire_date=date(2026, 8, 1), as_of=date(2026, 7, 15), termination_end_date=None, ) assert d.intent == LIFECYCLE_FUTURE_HIRE assert not d.is_leaver assert d.termination_date is None def test_day_one_hire_is_never_a_leaver(): """hire_date == as_of (the P10013 boundary) → still a future/day-one hire, NOT a leaver. This is the exact case the strict `>` regressed on.""" d = classify_inactive_sf_user( hire_date=date(2026, 7, 13), as_of=date(2026, 7, 13), termination_end_date=None, ) assert d.intent == LIFECYCLE_FUTURE_HIRE assert not d.is_leaver def test_day_one_hire_ignores_even_a_bogus_end_date(): """A future/day-one hire is not a leaver EVEN IF SF carries a nonsensical endDate (Camila's pre-start endDate shape). The hire-date rule wins.""" d = classify_inactive_sf_user( hire_date=date(2026, 7, 13), as_of=date(2026, 7, 13), termination_end_date=date(2026, 7, 12), # before start — bogus ) assert d.intent == LIFECYCLE_FUTURE_HIRE assert not d.is_leaver def test_substantiated_leaver_offboards(): d = classify_inactive_sf_user( hire_date=date(2019, 10, 28), as_of=date(2026, 7, 15), termination_end_date=date(2026, 3, 20), ) assert d.intent == LIFECYCLE_OFFBOARD assert d.is_leaver assert d.termination_date == date(2026, 3, 20) def test_forfeited_without_termination_is_not_fabricated(): """Past hire, inactive, but NO real endDate → NOT a leaver. We never invent a termination date; the divergence check surfaces it.""" d = classify_inactive_sf_user( hire_date=date(2019, 10, 28), as_of=date(2026, 7, 15), termination_end_date=None, ) assert d.intent == LIFECYCLE_NO_TERMINATION assert not d.is_leaver assert d.termination_date is None def test_impossible_end_before_hire_is_refused(): """A real endDate that precedes hire_date is logically impossible — refuse the offboard (this is the fabricated-lastModifiedDateTime P10013 shape if it ever reaches the classifier via a past hire_date).""" d = classify_inactive_sf_user( hire_date=date(2026, 7, 13), as_of=date(2026, 7, 20), # hire already past termination_end_date=date(2026, 7, 12), # before hire — impossible ) assert d.intent == LIFECYCLE_NO_TERMINATION assert not d.is_leaver def test_end_equal_to_hire_is_allowed(): """Same-day hire+term (rare but legal) offboards on that date.""" d = classify_inactive_sf_user( hire_date=date(2026, 1, 10), as_of=date(2026, 7, 15), termination_end_date=date(2026, 1, 10), ) assert d.intent == LIFECYCLE_OFFBOARD assert d.termination_date == date(2026, 1, 10) def test_no_hire_date_with_real_end_offboards(): """When SF gives no hire_date at all but a real endDate, we can't check the invariant — trust the substantiated termination and offboard.""" d = classify_inactive_sf_user( hire_date=None, as_of=date(2026, 7, 15), termination_end_date=date(2026, 6, 1), ) assert d.intent == LIFECYCLE_OFFBOARD assert d.termination_date == date(2026, 6, 1) # ── Termination-reason exclusion (2026-07-30, HRIS-aligned) ────────────── # SF writes a REAL EmpEmploymentTermination row + flips status='f' for # 人员分类转换 (D07) / 调往中国·德国数据中心 (D03/D08) / 兼岗结束 (D04) — # but the human keeps working. Codes on the configured exclusion list must # classify as NOT-a-leaver; everything else keeps the substantiated-endDate # verdict byte-identical. _EXCLUDED = frozenset({"D03", "D04", "D07", "D08"}) def test_excluded_reason_is_not_a_leaver(): d = classify_inactive_sf_user( hire_date=date(2019, 10, 28), as_of=date(2026, 7, 15), termination_end_date=date(2026, 7, 1), termination_reason_code="D07", excluded_reason_codes=_EXCLUDED, ) assert d.intent == LIFECYCLE_REASON_EXCLUDED assert not d.is_leaver def test_excluded_reason_match_is_case_insensitive(): d = classify_inactive_sf_user( hire_date=date(2019, 10, 28), as_of=date(2026, 7, 15), termination_end_date=date(2026, 7, 1), termination_reason_code=" d04 ", excluded_reason_codes=_EXCLUDED, ) assert d.intent == LIFECYCLE_REASON_EXCLUDED def test_non_excluded_reason_still_offboards(): d = classify_inactive_sf_user( hire_date=date(2019, 10, 28), as_of=date(2026, 7, 15), termination_end_date=date(2026, 7, 1), termination_reason_code="D01", # 主动离职 — real leaver excluded_reason_codes=_EXCLUDED, ) assert d.intent == LIFECYCLE_OFFBOARD assert d.is_leaver def test_missing_reason_keeps_prechange_verdict(): """No reason resolvable (lookup failed / row missing) → fail open to offboard: a broken lookup must never leave a real leaver's access alive.""" d = classify_inactive_sf_user( hire_date=date(2019, 10, 28), as_of=date(2026, 7, 15), termination_end_date=date(2026, 7, 1), termination_reason_code=None, excluded_reason_codes=_EXCLUDED, ) assert d.intent == LIFECYCLE_OFFBOARD def test_empty_exclusion_config_is_prechange_behaviour(): d = classify_inactive_sf_user( hire_date=date(2019, 10, 28), as_of=date(2026, 7, 15), termination_end_date=date(2026, 7, 1), termination_reason_code="D07", excluded_reason_codes=frozenset(), ) assert d.intent == LIFECYCLE_OFFBOARD def test_excluded_reason_never_masks_future_hire_rule(): """Rule order: future/day-one hire wins before any reason check.""" d = classify_inactive_sf_user( hire_date=date(2026, 8, 1), as_of=date(2026, 7, 15), termination_end_date=date(2026, 8, 31), termination_reason_code="D07", excluded_reason_codes=_EXCLUDED, ) assert d.intent == LIFECYCLE_FUTURE_HIRE def test_normalize_reason_codes_shapes(): assert normalize_reason_codes("D03, d04 D07,D08") == _EXCLUDED assert normalize_reason_codes(["d03", "D04", " D07 ", "D08"]) == _EXCLUDED assert normalize_reason_codes(None) == frozenset() assert normalize_reason_codes("") == frozenset() assert normalize_reason_codes({"nested": "dict"}) == frozenset() # Raw picklist option ids pass through for belt-and-suspenders configs. assert "2566" in normalize_reason_codes("2566 D03")