"""State-machine tests for the attendance confirmation lifecycle. Reports are seeded directly in the DB (generation is covered separately), so these focus purely on transitions, guards, RBAC, and idempotency. Notifications are best-effort and no-op without a Feishu integration.""" from __future__ import annotations import uuid from datetime import UTC, date, datetime, timedelta import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ForbiddenError, ValidationError from app.domains.attendance import constants as c from app.domains.attendance.lifecycle import AttendanceLifecycleService from app.domains.attendance.models import ( AttendanceCycle, AttendanceReport, AttendanceReportEvent, ) from app.identity.person_models import IdentityPerson async def _person(db, email, name, *, manager_id=None): p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=name, employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", manager_person_id=manager_id, ) db.add(p) await db.flush() return p async def _seed(db, *, status, with_leader=True, sent_ago_h=None): leader = await _person(db, f"ldr-{uuid.uuid4().hex[:6]}@x.com", "Leader") if with_leader else None intern = await _person( db, f"int-{uuid.uuid4().hex[:6]}@x.com", "Intern One", manager_id=leader.id if leader else None, ) cycle = AttendanceCycle( id=uuid.uuid4(), cycle_key="2026-06", start_date=date(2026, 5, 21), end_date=date(2026, 6, 20), ) db.add(cycle) await db.flush() last_sent = ( datetime.now(UTC) - timedelta(hours=sent_ago_h) if sent_ago_h is not None else None ) report = AttendanceReport( id=uuid.uuid4(), cycle_id=cycle.id, person_id=intern.id, leader_person_id=leader.id if leader else None, status=status, version=1, snapshot_json={"summary": {"abnormal_days": 1}, "days": []}, work_days=20, abnormal_days=1, lack_count=1, last_sent_at=last_sent, ) db.add(report) await db.flush() return report, intern, leader @pytest.mark.asyncio async def test_happy_path_two_stage(db_session: AsyncSession): report, intern, leader = await _seed(db_session, status=c.STATUS_GENERATED) svc = AttendanceLifecycleService(db_session) await svc.send_to_intern(report, actor_user_id=uuid.uuid4()) assert report.status == c.STATUS_PENDING_INTERN assert report.last_sent_at is not None await svc.intern_confirm(report, actor_person_id=intern.id) assert report.status == c.STATUS_PENDING_LEADER assert report.intern_confirmed_at is not None await svc.leader_confirm(report, actor_person_id=leader.id) assert report.status == c.STATUS_COMPLETED assert report.leader_confirmed_at is not None @pytest.mark.asyncio async def test_intern_confirm_without_leader_returns_to_hr(db_session: AsyncSession): report, intern, _ = await _seed( db_session, status=c.STATUS_PENDING_INTERN, with_leader=False, ) svc = AttendanceLifecycleService(db_session) await svc.intern_confirm(report, actor_person_id=intern.id) assert report.status == c.STATUS_RETURNED assert report.intern_confirmed_at is not None assert report.returned_by is None assert report.returned_by_person_id is None assert report.return_reason == "未配置直属 Leader,无法完成复核" events = (await db_session.execute( select(AttendanceReportEvent).where( AttendanceReportEvent.report_id == report.id, ) )).scalars().all() assert any(event.event_type == c.EVENT_LEADER_UNAVAILABLE for event in events) @pytest.mark.asyncio async def test_intern_return_goes_to_hr(db_session: AsyncSession): report, intern, _ = await _seed(db_session, status=c.STATUS_PENDING_INTERN) svc = AttendanceLifecycleService(db_session) await svc.intern_return(report, actor_person_id=intern.id, reason="5/22 缺卡未补") assert report.status == c.STATUS_RETURNED assert report.returned_by == c.SIDE_INTERN assert report.return_reason == "5/22 缺卡未补" @pytest.mark.asyncio async def test_leader_return_goes_to_hr(db_session: AsyncSession): report, _, leader = await _seed(db_session, status=c.STATUS_PENDING_LEADER) svc = AttendanceLifecycleService(db_session) await svc.leader_return(report, actor_person_id=leader.id, reason="数据不对") assert report.status == c.STATUS_RETURNED assert report.returned_by == c.SIDE_LEADER @pytest.mark.asyncio async def test_intern_undo_before_leader_acts(db_session: AsyncSession): report, intern, _ = await _seed(db_session, status=c.STATUS_PENDING_LEADER) report.intern_confirmed_at = datetime.now(UTC) svc = AttendanceLifecycleService(db_session) await svc.intern_undo(report, actor_person_id=intern.id) assert report.status == c.STATUS_PENDING_INTERN assert report.intern_confirmed_at is None @pytest.mark.asyncio async def test_intern_undo_rejected_after_completed(db_session: AsyncSession): report, intern, _ = await _seed(db_session, status=c.STATUS_COMPLETED) svc = AttendanceLifecycleService(db_session) with pytest.raises(ValidationError): await svc.intern_undo(report, actor_person_id=intern.id) @pytest.mark.asyncio async def test_rbac_non_intern_cannot_confirm(db_session: AsyncSession): report, _, _ = await _seed(db_session, status=c.STATUS_PENDING_INTERN) svc = AttendanceLifecycleService(db_session) with pytest.raises(ForbiddenError): await svc.intern_confirm(report, actor_person_id=uuid.uuid4()) @pytest.mark.asyncio async def test_rbac_non_leader_cannot_leader_confirm(db_session: AsyncSession): report, intern, _ = await _seed(db_session, status=c.STATUS_PENDING_LEADER) svc = AttendanceLifecycleService(db_session) # The intern is not the leader → forbidden. with pytest.raises(ForbiddenError): await svc.leader_confirm(report, actor_person_id=intern.id) @pytest.mark.asyncio async def test_send_guard_rejects_pending(db_session: AsyncSession): report, _, _ = await _seed(db_session, status=c.STATUS_PENDING_LEADER) svc = AttendanceLifecycleService(db_session) with pytest.raises(ValidationError): await svc.send_to_intern(report, actor_user_id=uuid.uuid4()) @pytest.mark.asyncio async def test_resend_rejected_for_pending_leader(db_session: AsyncSession): # Resending a report that's correctly with the leader would yank it # back and discard the intern's confirmation — must be rejected # before any re-pull. report, _, _ = await _seed(db_session, status=c.STATUS_PENDING_LEADER) svc = AttendanceLifecycleService(db_session) with pytest.raises(ValidationError): await svc.resend(report, actor_user_id=uuid.uuid4()) @pytest.mark.asyncio async def test_intern_confirm_idempotent(db_session: AsyncSession): report, intern, _ = await _seed(db_session, status=c.STATUS_PENDING_LEADER) svc = AttendanceLifecycleService(db_session) # Already past intern stage → no-op, no error. result = await svc.intern_confirm(report, actor_person_id=intern.id) assert result.status == c.STATUS_PENDING_LEADER @pytest.mark.asyncio async def test_withdraw_from_pending_intern(db_session: AsyncSession): report, _, _ = await _seed(db_session, status=c.STATUS_PENDING_INTERN) svc = AttendanceLifecycleService(db_session) await svc.withdraw(report, actor_user_id=uuid.uuid4()) assert report.status == c.STATUS_GENERATED @pytest.mark.asyncio async def test_withdraw_from_pending_leader_clears_intern_confirm(db_session: AsyncSession): report, _, _ = await _seed(db_session, status=c.STATUS_PENDING_LEADER) report.intern_confirmed_at = datetime.now(UTC) svc = AttendanceLifecycleService(db_session) await svc.withdraw(report, actor_user_id=uuid.uuid4()) assert report.status == c.STATUS_GENERATED assert report.intern_confirmed_at is None @pytest.mark.asyncio @pytest.mark.parametrize("status", [c.STATUS_GENERATED, c.STATUS_RETURNED, c.STATUS_COMPLETED]) async def test_withdraw_rejected_when_not_in_flight(db_session: AsyncSession, status): report, _, _ = await _seed(db_session, status=status) svc = AttendanceLifecycleService(db_session) with pytest.raises(ValidationError): await svc.withdraw(report, actor_user_id=uuid.uuid4()) @pytest.mark.asyncio @pytest.mark.parametrize( "status", [c.STATUS_GENERATED, c.STATUS_PENDING_INTERN, c.STATUS_PENDING_LEADER, c.STATUS_RETURNED], ) async def test_reinitiate_rejected_unless_completed(db_session: AsyncSession, status): report, _, _ = await _seed(db_session, status=status) svc = AttendanceLifecycleService(db_session) with pytest.raises(ValidationError): await svc.reinitiate(report, actor_user_id=uuid.uuid4()) @pytest.mark.asyncio async def test_reinitiate_reopens_completed(db_session: AsyncSession, monkeypatch): report, _, _ = await _seed(db_session, status=c.STATUS_COMPLETED) report.intern_confirmed_at = datetime.now(UTC) report.leader_confirmed_at = datetime.now(UTC) async def _fake_repull(self, rep, *, actor_user_id, trace_id): rep.version += 1 # mimic the version bump without hitting Feishu monkeypatch.setattr( "app.domains.attendance.service.AttendanceService.repull_report", _fake_repull, ) svc = AttendanceLifecycleService(db_session) await svc.reinitiate(report, actor_user_id=uuid.uuid4()) assert report.status == c.STATUS_PENDING_INTERN assert report.intern_confirmed_at is None assert report.leader_confirmed_at is None assert report.version == 2