"""Tests for the manual mark-leaver admin endpoint. POST /api/v1/hr/people/{person_id}/mark-leaver flips an active person to ``leaver_pending`` and starts the full offboarding flow — same path as HRIS sync, just with admin as the actor instead of the SF poller. """ from datetime import date, timedelta from uuid import uuid4 import pytest from sqlalchemy import select from app.audit.models import AuditLog from app.domains.offboarding.models import OffboardingProcess from app.identity.person_models import IdentityPerson @pytest.mark.asyncio async def test_mark_leaver_manual_happy_path(client, admin_user, db_session): """Active person → leaver_pending + OffboardingProcess + audit row.""" _, token = admin_user person = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-manual-leaver-001", employee_no="EMP-ML001", work_email="manual-leaver@company.com", display_name="Manual Leaver", employment_status="active", ) db_session.add(person) await db_session.commit() last_day = date.today() + timedelta(days=14) resp = await client.post( f"/api/v1/hr/people/{person.id}/mark-leaver", json={ "last_working_day": last_day.isoformat(), "reason": "Resignation — testing pipeline", }, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text data = resp.json()["data"] assert data["person_id"] == str(person.id) assert data["employment_status"] == "leaver_pending" assert data["termination_date"] == last_day.isoformat() assert data["offboarding_process_id"] refreshed = await db_session.scalar( select(IdentityPerson).where(IdentityPerson.id == person.id) ) assert refreshed is not None assert refreshed.employment_status == "leaver_pending" assert refreshed.termination_date == last_day proc = await db_session.scalar( select(OffboardingProcess).where(OffboardingProcess.person_id == person.id) ) assert proc is not None assert proc.leaver_date == last_day audit = await db_session.scalar( select(AuditLog).where( AuditLog.action == "hr.person.mark_leaver_manual", AuditLog.resource_id == str(person.id), ) ) assert audit is not None assert audit.status == "success" assert audit.detail["last_working_day"] == last_day.isoformat() assert audit.detail["reason"] == "Resignation — testing pipeline" @pytest.mark.asyncio async def test_mark_leaver_manual_rejects_non_active(client, admin_user, db_session): """Re-marking a person already in leaver_pending returns 409.""" _, token = admin_user person = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-manual-leaver-002", employee_no="EMP-ML002", work_email="already-leaver@company.com", display_name="Already Leaver", employment_status="leaver_pending", termination_date=date.today() + timedelta(days=7), ) db_session.add(person) await db_session.commit() resp = await client.post( f"/api/v1/hr/people/{person.id}/mark-leaver", json={"last_working_day": date.today().isoformat()}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 409 assert resp.json()["errors"][0]["code"] == "PERSON_NOT_ACTIVE" @pytest.mark.asyncio async def test_mark_leaver_manual_404_when_missing(client, admin_user): """Unknown person_id returns 404.""" _, token = admin_user resp = await client.post( f"/api/v1/hr/people/{uuid4()}/mark-leaver", json={"last_working_day": date.today().isoformat()}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 404 assert resp.json()["errors"][0]["code"] == "PERSON_NOT_FOUND" @pytest.mark.asyncio async def test_mark_leaver_manual_requires_admin(client, employee_user, db_session): """Non-admin users get 403.""" _, token = employee_user person = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id="sf-manual-leaver-003", employee_no="EMP-ML003", work_email="protected@company.com", display_name="Protected", employment_status="active", ) db_session.add(person) await db_session.commit() resp = await client.post( f"/api/v1/hr/people/{person.id}/mark-leaver", json={"last_working_day": date.today().isoformat()}, headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403