"""respx tests for the read-only Feishu attendance client. Covers email→user_id resolution, 50-user chunking + aggregation of user_tasks, and graceful 503 degradation when the attendance scope is missing (non-zero Feishu code).""" from __future__ import annotations import re import uuid from datetime import date import httpx import pytest import respx from app.approval.feishu_client import FeishuApprovalClient from app.domains.attendance.cycle import COMPANY_TZ, period_ending_in from app.domains.attendance.facts import build_person_snapshot from app.domains.attendance.feishu_attendance import ( AttendanceFeishuError, FeishuAttendanceClient, ) from app.domains.attendance.service import AttendanceService _TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" _BATCH_ID_RE = re.compile( r"https://open\.feishu\.cn/open-apis/contact/v3/users/batch_get_id.*" ) _USER_TASKS_RE = re.compile( r"https://open\.feishu\.cn/open-apis/attendance/v1/user_tasks/query.*" ) def _client() -> FeishuAttendanceClient: # Unique app_id per test so the process-wide token cache never bleeds # a cached token across tests (which would skip the token route). return FeishuAttendanceClient( FeishuApprovalClient(app_id=f"app-{uuid.uuid4().hex}", app_secret="secret") ) def _token_ok(): return httpx.Response(200, json={ "code": 0, "tenant_access_token": "t-abc", "expire": 7200, }) @respx.mock @pytest.mark.asyncio async def test_approved_hourly_leave_survives_client_overlay_and_snapshot(): from datetime import datetime respx.mock.post(_TOKEN_URL).mock(return_value=_token_ok()) respx.mock.post(re.compile( r"https://open\.feishu\.cn/open-apis/attendance/v1/user_approvals/query.*" )).mock(return_value=httpx.Response(200, json={ "code": 0, "data": {"user_approvals": [{ "user_id": "fs-1", "date": "20260721", "leaves": [{ "start_time": "2026-07-21 15:00:00", "end_time": "2026-07-21 19:00:00", "unit": 2, "interval": 14400, "i18n_names": {"ch": "(CN)实习生请假"}, }], }]}, })) period = period_ending_in(2026, 8, 21) approvals = await _client().query_user_approvals( ["fs-1"], date(2026, 7, 21), date(2026, 7, 21), ) def ts(hour, minute): return int(datetime(2026, 7, 21, hour, minute, tzinfo=COMPANY_TZ).timestamp()) snap = build_person_snapshot( [{"day": 20260721, "records": [{ "check_in_result": "Normal", "check_out_result": "NoNeedCheck", "check_in_record": {"check_time": ts(9, 26)}, "check_out_record": {"check_time": ts(15, 0)}, }]}], feishu_user_id="fs-1", period=period, approval_dates=AttendanceService._spans_to_overlay(approvals["fs-1"], period), ) assert snap["summary"]["work_days"] == 0.5 assert snap["days"][0]["leave_type"] == "(CN)实习生请假" @respx.mock @pytest.mark.asyncio async def test_batch_resolve_user_ids_maps_emails(): respx.mock.post(_TOKEN_URL).mock(return_value=_token_ok()) respx.mock.post(_BATCH_ID_RE).mock(return_value=httpx.Response(200, json={ "code": 0, "data": {"user_list": [ {"email": "a@test.com", "user_id": "fs-a"}, {"email": "b@test.com", "user_id": "fs-b"}, {"email": "missing@test.com"}, # no user_id → skipped ]}, })) out = await _client().batch_resolve_user_ids( ["A@test.com", "b@test.com", "missing@test.com"] ) assert out == {"a@test.com": "fs-a", "b@test.com": "fs-b"} @respx.mock @pytest.mark.asyncio async def test_query_user_tasks_chunks_and_aggregates(): respx.mock.post(_TOKEN_URL).mock(return_value=_token_ok()) tasks_route = respx.mock.post(_USER_TASKS_RE).mock( return_value=httpx.Response(200, json={ "code": 0, "data": { "user_task_results": [ {"user_id": "u", "day": 20260601, "records": []}, ], "invalid_user_ids": ["bad"], "unauthorized_user_ids": ["nope"], }, }) ) # 60 ids → two chunks (50 + 10). uids = [f"u{i}" for i in range(60)] res = await _client().query_user_tasks(uids, date(2026, 5, 21), date(2026, 6, 20)) assert tasks_route.call_count == 2 assert len(res.results) == 2 # one result per chunk assert res.invalid_user_ids == ["bad", "bad"] assert res.unauthorized_user_ids == ["nope", "nope"] @respx.mock @pytest.mark.asyncio async def test_query_user_tasks_empty_user_ids_no_call(): respx.mock.post(_TOKEN_URL).mock(return_value=_token_ok()) route = respx.mock.post(_USER_TASKS_RE).mock( return_value=httpx.Response(200, json={"code": 0, "data": {}}) ) res = await _client().query_user_tasks([], date(2026, 5, 21), date(2026, 6, 20)) assert res.results == [] assert route.call_count == 0 @respx.mock @pytest.mark.asyncio async def test_missing_scope_raises_clean_503(): respx.mock.post(_TOKEN_URL).mock(return_value=_token_ok()) respx.mock.post(_USER_TASKS_RE).mock(return_value=httpx.Response(200, json={ "code": 99991672, # permission / frequency-class failure "msg": "no permission", })) with pytest.raises(AttendanceFeishuError) as exc: await _client().query_user_tasks( ["u1"], date(2026, 5, 21), date(2026, 6, 20) ) assert exc.value.status_code == 503