"""Integration tests for attendance cycle generation + HR endpoints. The intern roster is sourced LIVE from the Feishu 通讯录 department (``attendance.intern_department``), matched to Helix persons by email. These tests mock the Feishu contact API (departments + find_by_department), the id-resolution (batch_get_id) and user_tasks with respx, and exercise: * generation → one report per *mapped* intern with correct summary + leader, * 「待补」 surfacing for a Feishu member with no Helix person, * data-quality flagging for an intern not resolvable to a Feishu attendance id, * idempotent re-generation (refresh in place, version bump), * RBAC (employee blocked), * list + detail endpoints (snapshot + events). """ from __future__ import annotations import re import uuid import httpx import pytest import respx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.attendance.models import AttendanceCycle, AttendanceReport 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): 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 def _token_ok(): return httpx.Response(200, json={ "code": 0, "tenant_access_token": "t", "expire": 7200, }) def _mock_feishu(*, members: list[dict], email_to_uid: dict[str, str], results: list[dict]): """members: [{name, email}] returned by the Feishu intern department.""" respx.mock.post(_TOKEN_URL).mock(return_value=_token_ok()) 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": []}, })) def _day(uid: str, day: int, ci: str, co: str, ci_supp="None", co_supp="None"): return { "user_id": uid, "day": day, "shift_id": "s", "group_id": "g", "check_in_shift_time": 1718935200, "check_out_shift_time": 1718967600, "records": [{ "check_in_result": ci, "check_out_result": co, "check_in_result_supplement": ci_supp, "check_out_result_supplement": co_supp, "check_in_record": {"check_time": "1718935320"}, "check_out_record": {"check_time": "1718967500"}, }], } @respx.mock @pytest.mark.asyncio async def test_generate_cycle_full_flow( client, db_session: AsyncSession, hr_headers, employee_headers, ): await _seed_feishu_integration(db_session) leader = await _person(db_session, "leader@test.com", "Team Leader") i1 = await _person(db_session, "intern1@test.com", "Intern One", manager_id=leader.id) i2 = await _person(db_session, "intern2@test.com", "Intern Two", manager_id=leader.id) # intern3 IS in Helix but Feishu attendance can't resolve their id → mapped # roster member, but report data_quality.feishu_mapped == False. i3 = await _person(db_session, "intern3@test.com", "Intern Three", manager_id=leader.id) await db_session.commit() _mock_feishu( members=[ {"name": "Intern One", "email": "intern1@test.com"}, {"name": "Intern Two", "email": "intern2@test.com"}, {"name": "Intern Three", "email": "intern3@test.com"}, # Feishu intern not in Helix at all → 待补. {"name": "Ghost Intern", "email": "ghost@test.com"}, ], email_to_uid={"intern1@test.com": "fs-1", "intern2@test.com": "fs-2"}, results=[ _day("fs-1", 20260601, "Normal", "Normal"), _day("fs-1", 20260602, "Lack", "Normal"), # abnormal: 缺卡 _day("fs-2", 20260601, "Normal", "Normal"), ], ) # RBAC: employee cannot generate forbidden = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=employee_headers, ) assert forbidden.status_code == 403 resp = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert resp.status_code == 200, resp.text stats = resp.json()["data"] assert stats["cycle_key"] == "2026-06" assert stats["roster_size"] == 3 # mapped Helix persons assert stats["feishu_members"] == 4 # incl. ghost assert stats["not_in_helix"] == 1 assert stats["created"] == 3 assert stats["unmapped"] == 1 # intern3 not feishu-attendance-mapped assert stats["with_abnormalities"] == 1 assert any(m["email"] == "ghost@test.com" for m in stats["not_in_helix_members"]) reports = ( await db_session.execute( select(AttendanceReport).order_by(AttendanceReport.person_id) ) ).scalars().all() by_person = {r.person_id: r for r in reports} assert len(by_person) == 3 r1 = by_person[i1.id] assert r1.leader_person_id == leader.id assert r1.work_days == 2 assert r1.abnormal_days == 1 assert r1.lack_count == 1 assert r1.data_quality_json["feishu_mapped"] is True # times enriched onto the snapshot segments assert r1.snapshot_json["days"][0]["segments"][0]["check_in_time"] is not None assert by_person[i2.id].abnormal_days == 0 assert by_person[i3.id].data_quality_json["feishu_mapped"] is False # cycle persists 待补 cyc = (await db_session.execute(select(AttendanceCycle))).scalar_one() assert any(m["email"] == "ghost@test.com" for m in (cyc.pending_not_in_helix_json or [])) # idempotent re-generation resp2 = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert resp2.status_code == 200, resp2.text assert resp2.json()["data"]["created"] == 0 assert resp2.json()["data"]["refreshed"] == 3 await db_session.refresh(r1) assert r1.version == 2 @respx.mock @pytest.mark.asyncio async def test_list_and_detail_endpoints( client, db_session: AsyncSession, hr_headers, ): await _seed_feishu_integration(db_session) leader = await _person(db_session, "lead2@test.com", "Lead Two") await _person(db_session, "solo@test.com", "Solo Intern", manager_id=leader.id) await db_session.commit() _mock_feishu( members=[{"name": "Solo Intern", "email": "solo@test.com"}], email_to_uid={"solo@test.com": "fs-solo"}, results=[_day("fs-solo", 20260601, "Late", "Normal")], ) gen = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert gen.status_code == 200, gen.text listed = await client.get("/api/v1/attendance/reports", headers=hr_headers) assert listed.status_code == 200 body = listed.json() assert body["meta"]["total"] == 1 item = body["data"][0] assert item["person_name"] == "Solo Intern" assert item["leader_name"] == "Lead Two" assert item["late_count"] == 1 report_id = item["id"] detail = await client.get( f"/api/v1/attendance/reports/{report_id}", headers=hr_headers, ) assert detail.status_code == 200 d = detail.json()["data"] assert d["snapshot"]["summary"]["late_count"] == 1 assert any(e["event_type"] == "generated" for e in d["events"]) cycles = await client.get("/api/v1/attendance/cycles", headers=hr_headers) assert cycles.status_code == 200 assert cycles.json()["data"][0]["report_count"] == 1 @respx.mock @pytest.mark.asyncio async def test_department_not_found_returns_503( client, db_session: AsyncSession, hr_headers, ): await _seed_feishu_integration(db_session) await db_session.commit() respx.mock.post(_TOKEN_URL).mock(return_value=_token_ok()) respx.mock.get(_DEPTS_RE).mock(return_value=httpx.Response(200, json={ "code": 0, "data": {"items": [], "has_more": False}, })) resp = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert resp.status_code == 503 @respx.mock @pytest.mark.asyncio async def test_is_attendance_subject_gates_menu( client, db_session: AsyncSession, hr_headers, ): """The 「我的考勤」 menu flag is true only for interns + their leaders.""" from app.auth.service import AuthService await _seed_feishu_integration(db_session) leader = await _person(db_session, "lead3@test.com", "Lead Three") intern = await _person(db_session, "solob@test.com", "Solo B", manager_id=leader.id) outsider = await _person(db_session, "outsider@test.com", "Outsider") await db_session.commit() _mock_feishu( members=[{"name": "Solo B", "email": "solob@test.com"}], email_to_uid={"solob@test.com": "fs-b"}, results=[_day("fs-b", 20260601, "Normal", "Normal")], ) gen = await client.post( "/api/v1/attendance/cycles/generate", json={"year": 2026, "month": 6}, headers=hr_headers, ) assert gen.status_code == 200, gen.text svc = AuthService(db_session) assert await svc.is_attendance_subject(intern.id) is True # intern assert await svc.is_attendance_subject(leader.id) is True # reviewing leader assert await svc.is_attendance_subject(outsider.id) is False # plain employee @respx.mock @pytest.mark.asyncio async def test_list_reports_rejects_unknown_status(client, hr_headers): resp = await client.get( "/api/v1/attendance/reports?status=bogus", headers=hr_headers, ) assert resp.status_code == 422