"""Regression: ``LeaverBlockedDashboardService.list_blocked`` must tolerate tz-naive ``first_blocked_at`` reads from TiDB. Confirmed prod 2026-05-07: TiDB returns ``DATETIME`` columns tz-naive even when the SQLAlchemy column is ``DateTime(timezone=True)``. Mixing that with ``datetime.now(UTC)`` in the row-build loop raised ``TypeError: can't subtract offset-naive and offset-aware datetimes``, which surfaced as a hard 500 + ``加载列表失败`` toast on ``/admin/leaver-blocked``. The sort_key fallback to ``datetime.now(UTC)`` had the same latent bug for any process with ``first_blocked_at IS NULL`` (currently zero in prod, but defence in depth). """ from __future__ import annotations from datetime import UTC, date, datetime, timedelta from uuid import uuid4 import pytest from app.domains.leaver_blocked.dashboard_service import LeaverBlockedDashboardService from app.domains.offboarding.models import ( PROC_BLOCKED, SLA_BREACH, SLA_NONE, OffboardingProcess, ) from app.identity.person_models import IdentityPerson @pytest.mark.asyncio async def test_list_blocked_handles_tz_naive_first_blocked_at(db_session): """Reproduces the prod 500: a single blocked process whose ``first_blocked_at`` is tz-naive must NOT raise on subtract.""" person = IdentityPerson( source_system="test", hris_person_id=f"P{uuid4().hex[:6]}", employee_no=f"E{uuid4().hex[:6]}", work_email=f"{uuid4().hex[:8]}@test.local", display_name="Tz Naive Person", employment_status="leaver_pending", ) db_session.add(person) await db_session.flush() # Save a tz-naive datetime — mirrors what TiDB hands back. naive_blocked_at = datetime(2026, 4, 30, 12, 0, 0) # no tzinfo proc = OffboardingProcess( id=uuid4(), person_id=person.id, leaver_date=date(2026, 4, 28), status=PROC_BLOCKED, first_blocked_at=naive_blocked_at, sla_breach_level=SLA_BREACH, ) db_session.add(proc) await db_session.flush() rows, total = await LeaverBlockedDashboardService(db_session).list_blocked() assert total == 1 assert len(rows) == 1 # blocked_for_hours got computed without raising, and the value is # in the right ballpark (test runs days after 2026-04-30). assert rows[0].blocked_for_hours is not None assert rows[0].blocked_for_hours >= 0 @pytest.mark.asyncio async def test_list_blocked_handles_null_first_blocked_at_alongside_naive(db_session): """Defence-in-depth: the sort_key falls back to ``datetime.now(UTC)`` when ``first_blocked_at IS NULL``. Mixing that aware fallback with another row's tz-naive value in a single sort would raise TypeError unless the helper normalizes both.""" persons = [] for i in range(2): p = IdentityPerson( source_system="test", hris_person_id=f"P{uuid4().hex[:6]}", employee_no=f"E{uuid4().hex[:6]}", work_email=f"sortguard{i}-{uuid4().hex[:6]}@test.local", display_name=f"Sort Guard {i}", employment_status="leaver_pending", ) db_session.add(p) persons.append(p) await db_session.flush() # Row A — tz-naive first_blocked_at. db_session.add(OffboardingProcess( id=uuid4(), person_id=persons[0].id, leaver_date=date(2026, 4, 28), status=PROC_BLOCKED, first_blocked_at=datetime(2026, 4, 30, 12, 0, 0), sla_breach_level=SLA_NONE, )) # Row B — first_blocked_at NULL (sort key uses now-UTC fallback). db_session.add(OffboardingProcess( id=uuid4(), person_id=persons[1].id, leaver_date=date(2026, 4, 28), status=PROC_BLOCKED, first_blocked_at=None, sla_breach_level=SLA_NONE, )) await db_session.flush() # Used to raise: ``TypeError: can't compare offset-naive and # offset-aware datetimes`` inside the sort. rows, total = await LeaverBlockedDashboardService(db_session).list_blocked() assert total == 2 assert len(rows) == 2 @pytest.mark.asyncio async def test_list_blocked_handles_tz_aware_first_blocked_at_unchanged(db_session): """Sanity: a tz-aware ``first_blocked_at`` (the canonical Python write path) still computes hours correctly — the new naive guard must NOT double-shift an already-aware datetime.""" person = IdentityPerson( source_system="test", hris_person_id=f"P{uuid4().hex[:6]}", employee_no=f"E{uuid4().hex[:6]}", work_email=f"{uuid4().hex[:8]}@test.local", display_name="Tz Aware Person", employment_status="leaver_pending", ) db_session.add(person) await db_session.flush() aware_blocked_at = datetime.now(UTC) - timedelta(hours=10) db_session.add(OffboardingProcess( id=uuid4(), person_id=person.id, leaver_date=date(2026, 4, 28), status=PROC_BLOCKED, first_blocked_at=aware_blocked_at, sla_breach_level=SLA_BREACH, )) await db_session.flush() rows, total = await LeaverBlockedDashboardService(db_session).list_blocked() assert total == 1 assert rows[0].blocked_for_hours is not None # ~10h ago — allow ±1 to absorb test runtime. assert 9 <= rows[0].blocked_for_hours <= 11