"""Local-timezone gate for leaver shutdown (2026-05-21 hotfix). A US employee whose ``termination_date='2026-05-21'`` must remain fully active through 2026-05-21 23:59 PDT — anything else cuts their mailbox at 2026-05-21 00:00 UTC (= 2026-05-20 17:00 PDT, *before* their last working day even starts in LA) and they cannot send the farewell mail. Two gates need to stay in lock-step: * :func:`app.auth.service.is_effectively_departed` — blocks Google SSO / Feishu SSO / password login / JWT middleware. Returning True here locks the user out of every authenticated surface. * :meth:`app.domains.hr.service.SuccessFactorsLifecycleService.transition_overdue_leavers` — flips ``employment_status`` from ``leaver_pending`` to ``left``, which fans out to seat-sync / SaaS de-provision via the listener. Both gates resolve the recipient's IANA timezone from ``IdentityPerson.country`` and compare ``termination_date`` against the *local* date, with Asia/Shanghai as the fallback for unknown / NULL country. """ from __future__ import annotations import uuid from datetime import UTC, date, datetime from unittest.mock import patch import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import is_effectively_departed from app.domains.hr.service import SuccessFactorsLifecycleService from app.identity.person_models import IdentityPerson class _FakeUser: """Minimal duck-typed stand-in. ``is_effectively_departed`` reads only ``employment_status``, ``termination_date``, ``country``; ``resolve_timezone`` reads ``country`` and (for multi-zone countries) ``city``.""" def __init__( self, *, employment_status: str, termination_date: date | None = None, country: str | None = "CN", city: str | None = None, hris_record_state: str = "present", ): self.employment_status = employment_status self.termination_date = termination_date self.country = country # Mirror the real IdentityPerson column so resolve_timezone's # city-refinement path (multi-zone countries) can read it. self.city = city # Mirror the real IdentityPerson column (NOT NULL, default # 'present'); is_effectively_departed now also blocks 'purged'. self.hris_record_state = hris_record_state def _fake_user(**kwargs) -> _FakeUser: return _FakeUser(**kwargs) # --------------------------------------------------------------------------- # is_effectively_departed: local timezone gate # --------------------------------------------------------------------------- class TestIsEffectivelyDepartedLocalTz: """The auth-layer gate must respect the user's own clock.""" def test_us_leaver_on_last_day_at_utc_midnight_not_departed(self): """The bug condition: termination_date rolls in UTC but local is still the day before. US leaver remains active for their full last working day in PDT.""" # 2026-05-21 00:00 UTC = 2026-05-20 17:00 PDT. # US employee's last working day is 2026-05-21 — in LA they # haven't even started it yet. Must NOT be locked out. frozen_utc = datetime(2026, 5, 21, 0, 0, tzinfo=UTC) user = _fake_user( employment_status="leaver_pending", termination_date=date(2026, 5, 21), country="US", ) with patch("app.auth.service.datetime") as mock_dt: mock_dt.now.return_value = frozen_utc mock_dt.side_effect = lambda *a, **k: datetime(*a, **k) assert is_effectively_departed(user) is False def test_us_leaver_on_last_day_at_utc_evening_not_departed(self): """US leaver mid-last-day still active. 2026-05-21 23:00 UTC = 2026-05-21 16:00 PDT — still on the clock.""" frozen_utc = datetime(2026, 5, 21, 23, 0, tzinfo=UTC) user = _fake_user( employment_status="leaver_pending", termination_date=date(2026, 5, 21), country="US", ) with patch("app.auth.service.datetime") as mock_dt: mock_dt.now.return_value = frozen_utc assert is_effectively_departed(user) is False def test_us_leaver_after_local_midnight_is_departed(self): """The local rollover. 2026-05-22 07:00 UTC = 2026-05-22 00:00 PDT — termination_date 2026-05-21 is now past in LA.""" frozen_utc = datetime(2026, 5, 22, 7, 0, tzinfo=UTC) user = _fake_user( employment_status="leaver_pending", termination_date=date(2026, 5, 21), country="US", ) with patch("app.auth.service.datetime") as mock_dt: mock_dt.now.return_value = frozen_utc assert is_effectively_departed(user) is True def test_china_leaver_on_last_day_morning_not_departed(self): """CN leaver on last day. 2026-05-21 01:00 UTC = 2026-05-21 09:00 Asia/Shanghai — still on the last working day.""" frozen_utc = datetime(2026, 5, 21, 1, 0, tzinfo=UTC) user = _fake_user( employment_status="leaver_pending", termination_date=date(2026, 5, 21), country="CN", ) with patch("app.auth.service.datetime") as mock_dt: mock_dt.now.return_value = frozen_utc assert is_effectively_departed(user) is False def test_china_leaver_yesterday_at_utc_morning_is_departed(self): """CN leaver post-last-day. 2026-05-22 05:00 UTC = 2026-05-22 13:00 Asia/Shanghai — yesterday's termination_date is past.""" frozen_utc = datetime(2026, 5, 22, 5, 0, tzinfo=UTC) user = _fake_user( employment_status="leaver_pending", termination_date=date(2026, 5, 21), country="CN", ) with patch("app.auth.service.datetime") as mock_dt: mock_dt.now.return_value = frozen_utc assert is_effectively_departed(user) is True def test_unknown_country_falls_back_to_shanghai(self): """NULL country → Asia/Shanghai (HQ default). Must not crash and must apply HQ-tz semantics.""" frozen_utc = datetime(2026, 5, 21, 1, 0, tzinfo=UTC) user = _fake_user( employment_status="leaver_pending", termination_date=date(2026, 5, 21), country=None, ) with patch("app.auth.service.datetime") as mock_dt: mock_dt.now.return_value = frozen_utc # 2026-05-21 09:00 SHA on a date == termination_date. assert is_effectively_departed(user) is False def test_already_left_always_departed_regardless_of_tz(self): """The ``left`` short-circuit stays — already-departed users don't get a tz reprieve.""" user = _fake_user( employment_status="left", termination_date=date(2026, 5, 21), country="US", ) assert is_effectively_departed(user) is True def test_no_termination_date_never_departed(self): """leaver_pending without a date is held indefinitely (was already the behaviour; reaffirm it survives the tz refactor).""" user = _fake_user( employment_status="leaver_pending", termination_date=None, country="US", ) assert is_effectively_departed(user) is False # --------------------------------------------------------------------------- # transition_overdue_leavers: cron-level gate mirrors is_effectively_departed # --------------------------------------------------------------------------- async def _make_leaver( db: AsyncSession, *, email: str, country: str, termination_date: date, ) -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0], employment_status="leaver_pending", termination_date=termination_date, source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", country=country, ) db.add(p) await db.commit() return p @pytest.mark.asyncio async def test_cron_defers_us_leaver_on_local_last_day( db_session: AsyncSession, monkeypatch, ): """The cron tick at 2026-05-21 00:00 UTC must NOT flip a US leaver whose last day is 2026-05-21 — they're still 2026-05-20 17:00 PDT.""" yesterday_utc = date(2026, 5, 21) # = "today" for the SQL prefilter p = await _make_leaver( db_session, email="us-leaver@test.com", country="US", termination_date=yesterday_utc, ) svc = SuccessFactorsLifecycleService(db_session, today=yesterday_utc) # Pin datetime.now(UTC) inside hr.service to the borderline moment. import app.domains.hr.service as svc_mod pinned = datetime(2026, 5, 21, 0, 0, tzinfo=UTC) class _FrozenDatetime(datetime): @classmethod def now(cls, tz=None): # type: ignore[override] return pinned.astimezone(tz) if tz else pinned.replace(tzinfo=None) monkeypatch.setattr(svc_mod, "datetime", _FrozenDatetime) summary = await svc.transition_overdue_leavers() assert summary["transitioned_count"] == 0 assert str(p.id) in summary["deferred_user_ids"] await db_session.refresh(p) assert p.employment_status == "leaver_pending" @pytest.mark.asyncio async def test_cron_transitions_us_leaver_after_local_midnight( db_session: AsyncSession, monkeypatch, ): """Same US leaver, next tick after their local rollover → 2026-05-22 07:00 UTC = 2026-05-22 00:00 PDT → flip to left.""" p = await _make_leaver( db_session, email="us-leaver-rolled@test.com", country="US", termination_date=date(2026, 5, 21), ) svc = SuccessFactorsLifecycleService( db_session, today=date(2026, 5, 22), ) import app.domains.hr.service as svc_mod pinned = datetime(2026, 5, 22, 7, 0, tzinfo=UTC) class _FrozenDatetime(datetime): @classmethod def now(cls, tz=None): # type: ignore[override] return pinned.astimezone(tz) if tz else pinned.replace(tzinfo=None) monkeypatch.setattr(svc_mod, "datetime", _FrozenDatetime) summary = await svc.transition_overdue_leavers() assert summary["transitioned_count"] == 1 assert str(p.id) in summary["transitioned_user_ids"] assert summary["deferred_user_ids"] == [] await db_session.refresh(p) assert p.employment_status == "left" @pytest.mark.asyncio async def test_cron_transitions_china_leaver_one_day_past( db_session: AsyncSession, monkeypatch, ): """CN leaver, termination_date=yesterday UTC, current local already rolled to the next day → flip.""" p = await _make_leaver( db_session, email="cn-leaver@test.com", country="CN", termination_date=date(2026, 5, 20), ) svc = SuccessFactorsLifecycleService( db_session, today=date(2026, 5, 21), ) import app.domains.hr.service as svc_mod pinned = datetime(2026, 5, 21, 5, 0, tzinfo=UTC) # 13:00 SHA class _FrozenDatetime(datetime): @classmethod def now(cls, tz=None): # type: ignore[override] return pinned.astimezone(tz) if tz else pinned.replace(tzinfo=None) monkeypatch.setattr(svc_mod, "datetime", _FrozenDatetime) summary = await svc.transition_overdue_leavers() assert summary["transitioned_count"] == 1 assert str(p.id) in summary["transitioned_user_ids"] await db_session.refresh(p) assert p.employment_status == "left" @pytest.mark.asyncio async def test_cron_defers_china_leaver_on_local_last_day( db_session: AsyncSession, monkeypatch, ): """CN leaver on their last day; UTC ticked into termination_date (Asia/Shanghai started it 8 h ago) but local date == termination_date → still active, defer.""" p = await _make_leaver( db_session, email="cn-last-day@test.com", country="CN", termination_date=date(2026, 5, 21), ) svc = SuccessFactorsLifecycleService( db_session, today=date(2026, 5, 21), ) import app.domains.hr.service as svc_mod pinned = datetime(2026, 5, 21, 1, 0, tzinfo=UTC) # 09:00 SHA class _FrozenDatetime(datetime): @classmethod def now(cls, tz=None): # type: ignore[override] return pinned.astimezone(tz) if tz else pinned.replace(tzinfo=None) monkeypatch.setattr(svc_mod, "datetime", _FrozenDatetime) summary = await svc.transition_overdue_leavers() assert summary["transitioned_count"] == 0 assert str(p.id) in summary["deferred_user_ids"] await db_session.refresh(p) assert p.employment_status == "leaver_pending" @pytest.mark.asyncio async def test_list_overdue_leavers_hides_deferred( db_session: AsyncSession, monkeypatch, ): """The admin dashboard view must mirror the cron gate — otherwise IT will see a US leaver in the "overdue" list and manually shut them down, defeating the local-tz protection.""" us = await _make_leaver( db_session, email="dash-us@test.com", country="US", termination_date=date(2026, 5, 21), ) cn_past = await _make_leaver( db_session, email="dash-cn-past@test.com", country="CN", termination_date=date(2026, 5, 19), ) svc = SuccessFactorsLifecycleService( db_session, today=date(2026, 5, 21), ) import app.domains.hr.service as svc_mod pinned = datetime(2026, 5, 21, 1, 0, tzinfo=UTC) # 09:00 SHA / 18:00 prev-day PDT class _FrozenDatetime(datetime): @classmethod def now(cls, tz=None): # type: ignore[override] return pinned.astimezone(tz) if tz else pinned.replace(tzinfo=None) monkeypatch.setattr(svc_mod, "datetime", _FrozenDatetime) out = await svc.list_overdue_leavers() out_ids = [row["user_id"] for row in out] assert str(us.id) not in out_ids # still mid-last-day in PDT assert str(cn_past.id) in out_ids # 2 days past in SHA