"""Tests for the HR-maintained intern-roster overlay (include / exclude). The base roster is the Feishu 通讯录 department; these tests verify the overlay patches it correctly at generation time and that the CRUD endpoints behave (upsert / list / delete + RBAC). """ from __future__ import annotations import re import uuid import httpx import pytest import respx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.domains.attendance.models import AttendanceReport, AttendanceRosterOverride from app.domains.attendance.service import AttendanceService from app.identity.person_models import IdentityPerson from app.integrations.models import Integration _TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" _DEPTS_RE = re.compile(r".*/contact/v3/departments.*") _FIND_BY_DEPT_RE = re.compile(r".*/contact/v3/users/find_by_department.*") _BATCH_ID_RE = re.compile(r".*/contact/v3/users/batch_get_id.*") _USER_TASKS_RE = re.compile(r".*/attendance/v1/user_tasks/query.*") _USER_APPROVALS_RE = re.compile(r".*/attendance/v1/user_approvals/query.*") INTERN_DEPT = "PingCAP Intern" async def _seed_feishu_integration(db: AsyncSession) -> None: db.add(Integration( kind="chat_approval", provider="feishu", name="Primary Feishu", slug=f"feishu-att-{uuid.uuid4().hex[:8]}", enabled=True, is_primary=True, config_public={ "app_id": f"itest-{uuid.uuid4().hex[:8]}", "app_secret": "itest-secret", }, )) await db.flush() async def _person(db: AsyncSession, email: str, name: str, *, manager_id=None, status="active"): p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=name, employment_status=status, 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 def _mock_feishu(*, members: list[dict], email_to_uid: dict[str, str], results: list[dict]): respx.mock.post(_TOKEN_URL).mock(return_value=httpx.Response(200, json={ "code": 0, "tenant_access_token": "t", "expire": 7200, })) respx.mock.get(_DEPTS_RE).mock(return_value=httpx.Response(200, json={ "code": 0, "data": { "items": [ {"name": INTERN_DEPT, "open_department_id": "od-intern", "department_id": "100"}, ], "has_more": False, }, })) respx.mock.get(_FIND_BY_DEPT_RE).mock(return_value=httpx.Response(200, json={ "code": 0, "data": { "items": [ {"name": m["name"], "open_id": f"ou-{i}", "enterprise_email": m["email"]} for i, m in enumerate(members) ], "has_more": False, }, })) respx.mock.post(_BATCH_ID_RE).mock(return_value=httpx.Response(200, json={ "code": 0, "data": {"user_list": [ {"email": e, "user_id": u} for e, u in email_to_uid.items() ]}, })) respx.mock.post(_USER_TASKS_RE).mock(return_value=httpx.Response(200, json={ "code": 0, "data": { "user_task_results": results, "invalid_user_ids": [], "unauthorized_user_ids": [], }, })) respx.mock.post(_USER_APPROVALS_RE).mock(return_value=httpx.Response(200, json={ "code": 0, "data": {"user_approvals": []}, })) @respx.mock @pytest.mark.asyncio async def test_overlay_include_and_exclude_at_generation( client, db_session: AsyncSession, hr_headers, ): await _seed_feishu_integration(db_session) leader = await _person(db_session, "leader@test.com", "Team Leader") # in the Feishu dept, but HR will EXCLUDE them feishu_only = await _person(db_session, "in-feishu@test.com", "In Feishu", manager_id=leader.id) # NOT in the Feishu dept, but HR will INCLUDE them (the 许晴 case) manual = await _person(db_session, "manual@test.com", "Manual Intern", manager_id=leader.id) await db_session.commit() # HR sets the overlay: exclude the Feishu member, include the outsider. r_excl = await client.put( "/api/v1/attendance/roster/overrides", json={"person_id": str(feishu_only.id), "mode": "exclude"}, headers=hr_headers, ) assert r_excl.status_code == 200, r_excl.text r_incl = await client.put( "/api/v1/attendance/roster/overrides", json={"person_id": str(manual.id), "mode": "include", "note": "onboarded, not in Feishu"}, headers=hr_headers, ) assert r_incl.status_code == 200, r_incl.text _mock_feishu( members=[{"name": "In Feishu", "email": "in-feishu@test.com"}], email_to_uid={"manual@test.com": "fs-m", "in-feishu@test.com": "fs-f"}, results=[], ) resp = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert resp.status_code == 200, resp.text reports = (await db_session.execute(select(AttendanceReport))).scalars().all() person_ids = {r.person_id for r in reports} # manual include pulled in, feishu member excluded out assert manual.id in person_ids assert feishu_only.id not in person_ids # included person's leader still snapshotted from manager_person_id manual_report = next(r for r in reports if r.person_id == manual.id) assert manual_report.leader_person_id == leader.id @respx.mock @pytest.mark.asyncio async def test_override_crud_and_upsert( client, db_session: AsyncSession, hr_headers, employee_headers, ): p = await _person(db_session, "p@test.com", "Person P") await db_session.commit() # RBAC: employee blocked blocked = await client.put( "/api/v1/attendance/roster/overrides", json={"person_id": str(p.id), "mode": "include"}, headers=employee_headers, ) assert blocked.status_code == 403 # create include await client.put( "/api/v1/attendance/roster/overrides", json={"person_id": str(p.id), "mode": "include"}, headers=hr_headers, ) listed = (await client.get("/api/v1/attendance/roster/overrides", headers=hr_headers)).json()["data"] assert len(listed["includes"]) == 1 assert listed["includes"][0]["person_id"] == str(p.id) assert len(listed["excludes"]) == 0 # upsert: switch to exclude — same row, no duplicate await client.put( "/api/v1/attendance/roster/overrides", json={"person_id": str(p.id), "mode": "exclude"}, headers=hr_headers, ) rows = (await db_session.execute(select(AttendanceRosterOverride))).scalars().all() assert len(rows) == 1 listed = (await client.get("/api/v1/attendance/roster/overrides", headers=hr_headers)).json()["data"] assert len(listed["includes"]) == 0 assert len(listed["excludes"]) == 1 # delete d = await client.delete(f"/api/v1/attendance/roster/overrides/{p.id}", headers=hr_headers) assert d.status_code == 200 rows = (await db_session.execute(select(AttendanceRosterOverride))).scalars().all() assert len(rows) == 0 # delete again → 404 d2 = await client.delete(f"/api/v1/attendance/roster/overrides/{p.id}", headers=hr_headers) assert d2.status_code == 404 @respx.mock @pytest.mark.asyncio async def test_exclude_added_after_generation_prunes_on_regenerate( client, db_session: AsyncSession, hr_headers, ): """Regression (HR report, 2026-07-10): HR generates a cycle, THEN adds a manual exclude, then regenerates — the excluded person's already-created report row must be removed, not silently left in the cycle. In-flight / completed reports are kept (never clobbered by regeneration) and counted in ``excluded_in_flight``. """ await _seed_feishu_integration(db_session) leader = await _person(db_session, "leader2@test.com", "Leader Two") intern_a = await _person(db_session, "int-a@test.com", "Intern A", manager_id=leader.id) intern_b = await _person(db_session, "int-b@test.com", "Intern B", manager_id=leader.id) intern_c = await _person(db_session, "int-c@test.com", "Intern C", manager_id=leader.id) a_id, b_id, c_id = intern_a.id, intern_b.id, intern_c.id await db_session.commit() _mock_feishu( members=[ {"name": "Intern A", "email": "int-a@test.com"}, {"name": "Intern B", "email": "int-b@test.com"}, {"name": "Intern C", "email": "int-c@test.com"}, ], email_to_uid={ "int-a@test.com": "fs-a", "int-b@test.com": "fs-b", "int-c@test.com": "fs-c", }, results=[], ) # First generation: all three get reports. resp = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["created"] == 3 # B's report moves in-flight (sent to the intern) before HR excludes. b_report = ( await db_session.execute( select(AttendanceReport).where(AttendanceReport.person_id == b_id) ) ).scalar_one() b_report.status = "pending_intern" await db_session.commit() # HR now excludes A (generated) and B (in-flight), then regenerates. for pid in (a_id, b_id): r = await client.put( "/api/v1/attendance/roster/overrides", json={"person_id": str(pid), "mode": "exclude"}, headers=hr_headers, ) assert r.status_code == 200, r.text resp2 = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert resp2.status_code == 200, resp2.text stats = resp2.json()["data"] assert stats["removed_excluded"] == 1 # A pruned assert stats["excluded_in_flight"] == 1 # B kept — in flight assert stats["refreshed"] == 1 # C refreshed as usual db_session.expire_all() reports = (await db_session.execute(select(AttendanceReport))).scalars().all() person_ids = {r.person_id for r in reports} assert a_id not in person_ids assert b_id in person_ids assert c_id in person_ids # A third regenerate is a no-op for the pruned person (idempotent). resp3 = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert resp3.status_code == 200, resp3.text assert resp3.json()["data"]["removed_excluded"] == 0 @pytest.mark.asyncio async def test_set_override_concurrent_insert_recovers_not_500( db_engine, db_session: AsyncSession, ): """Two concurrent PUTs for a not-yet-overridden person must not 500. Regression: ``set_roster_override`` did a SELECT-then-INSERT with no guard against ``UNIQUE(person_id)``. When two requests race (HR double-click / client retry), both read ``None`` and both INSERT; the loser raised an unhandled ``IntegrityError`` → HTTP 500. The loser must instead recover into the same in-place upsert (locking re-select so the concurrent winner is visible past this txn's REPEATABLE READ snapshot). """ p = await _person(db_session, "race@test.com", "Race Person") await db_session.commit() svc = AttendanceService(db_session) # Pin this session's REPEATABLE READ snapshot BEFORE the winner exists, # so the service's internal SELECT can't see the concurrent insert and # is forced down the INSERT path → IntegrityError → recovery branch. assert await svc._get_roster_override(p.id) is None # A concurrent request (separate txn) creates the winner row first. winner_factory = async_sessionmaker( db_engine, class_=AsyncSession, expire_on_commit=False, ) async with winner_factory() as other: other.add(AttendanceRosterOverride(person_id=p.id, mode="include")) await other.commit() # Loser now upserts to 'exclude': hits the unique collision, recovers, # and updates the winner row in place — no 500, exactly one row. row = await svc.set_roster_override( person_id=p.id, mode="exclude", note="loser", actor_user_id=p.id, ) assert row.mode == "exclude" await db_session.commit() rows = ( await db_session.execute(select(AttendanceRosterOverride)) ).scalars().all() assert len(rows) == 1 assert rows[0].mode == "exclude"