"""代提回购 — admin-initiated offboarding buyback for leaver-held assets. A departing employee can't self-submit the offboarding buyback once they're gone (their Feishu account is frozen). This flow lets an asset admin start the SAME approval on their behalf: * the admin is the Feishu *applicant* (active account), * the stored ``requester_id`` is the *leaver* (so the approve-time auto-dispose TOCTOU check ``requester == asset.assigned_to`` passes and the disposal is attributed to them), * the amount is server-enforced and the last-working-day is auto-filled from the leaver's HR record. Endpoint: ``POST /api/v1/approval/asset/requests/on-behalf``. """ import json from datetime import UTC, date, datetime from decimal import Decimal from unittest.mock import AsyncMock, patch import pytest import pytest_asyncio from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.models import AssetEmployeeRequest, AssetItem @pytest.fixture(autouse=True) def _approval_idempotency_headers(request): for fixture_name in ("employee_headers", "admin_headers"): if fixture_name in request.fixturenames: request.getfixturevalue(fixture_name)["Idempotency-Key"] = ( f"test-{request.node.name}" ) _FAKE_FEISHU_CFG = { "app_id": "cli_test", "app_secret": "secret_test", "event_verification_token": "test-feishu-token", } _FAKE_APPROVAL_FLOWS = { "laptop_replacement": "7694D6BE-7FEE-4E49-96B5-A106FC4623DD", "computer_borrow": "8D3A735C-B91C-4EF0-9476-6517EC31B110", "asset_return": "E4ED0075-D2D2-479F-85F7-11374B0CC626", "offboarding_buyback": "3B6AAF34-4762-4A24-AE3A-8E035B7BEDA8", } _OFFBOARDING_BUYBACK_FORM = [ {"id": "resign_date", "name": "Resignation Date", "type": "date", "required": True}, {"id": "serial", "name": "Buyback Device Serial Number", "type": "input", "required": True}, {"id": "amount", "name": "Amount", "type": "amount", "required": True, # Mirror the prod multi-currency "Repurchase price" widget so a # non-default currency (JPY for a Japan buyback) is labelled right. "option": {"currencyRange": ["CNY", "USD", "JPY", "SGD", "MYR"], "isCapital": False}}, ] _OFFBOARDING_BUYBACK_FORM_WITH_LOCATION = [ *_OFFBOARDING_BUYBACK_FORM, { "id": "base_location", "name": "Base location selection", "type": "radioV2", "required": True, "option": { "options": [ {"value": "loc-jp", "text": "Japan"}, {"value": "loc-cn", "text": "China"}, ], }, }, ] @pytest.fixture(autouse=True) def _patch_feishu_config(monkeypatch): async def _fake_cfg(**_kwargs): return dict(_FAKE_FEISHU_CFG) async def _fake_flows(**_kwargs): return dict(_FAKE_APPROVAL_FLOWS) monkeypatch.setattr("app.integrations.compat.get_feishu_config", _fake_cfg) monkeypatch.setattr("app.approval.service.get_feishu_config", _fake_cfg) monkeypatch.setattr( "app.approval.router.get_feishu_config", _fake_cfg, raising=False, ) monkeypatch.setattr( "app.integrations.service.get_approval_flows_map", _fake_flows, ) async def _fake_token(self): return "fake-tenant-token" monkeypatch.setattr( "app.approval.feishu_client.FeishuApprovalClient.get_tenant_access_token", _fake_token, ) @pytest_asyncio.fixture async def leaver_user(db_session: AsyncSession): """A departed employee with a recorded last working day.""" import uuid from app.identity.person_models import IdentityPerson user = IdentityPerson( id=uuid.uuid4(), work_email="leaver@test.com", display_name="Departed Employee", employment_status="left", source_system="successfactors", hris_person_id="sf-leaver-001", termination_date=date(2026, 6, 15), ) db_session.add(user) await db_session.flush() await db_session.commit() return user def _patch_feishu_calls(captured: dict, *, form=None): """Patch the three Feishu round-trips, capturing the applicant user_id and the serialized form so the test can assert on both.""" async def _capture_create_instance( self, *, approval_code, user_id, form, instance_uuid=None ): captured["user_id"] = user_id captured["form"] = form return {"instance_code": "feishu_on_behalf_001", "instance_status": "PENDING"} # Echo the email back so the test can prove WHICH person became the # Feishu applicant (admin's email, not the leaver's). async def _resolve(self, email): return f"feishu::{email}" return ( patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock(return_value={ "approval_name": "Laptop Purchase Request While Offboarding", "form": form or _OFFBOARDING_BUYBACK_FORM, }), ), patch( "app.approval.service.FeishuApprovalClient.resolve_user_id_by_email", new=_resolve, ), patch( "app.approval.service.FeishuApprovalClient.create_instance", new=_capture_create_instance, ), ) @pytest.mark.asyncio async def test_admin_buyback_on_behalf_of_leaver( client: AsyncClient, admin_user, admin_headers: dict, leaver_user, db_session: AsyncSession, ): """Happy path: admin starts the buyback for a held_by_leaver asset. Asserts the Feishu applicant is the ADMIN, the stored requester is the LEAVER, the amount is the server-canonical policy quote, and the last working day is auto-filled from the leaver's termination_date. """ admin, _ = admin_user asset = AssetItem( asset_tag="BB-ONBEHALF-1", category="laptop", status="held_by_leaver", assigned_to=leaver_user.id, serial_number="SN-ONBEHALF-1", purchase_cost=Decimal("5000"), currency="CNY", # ≥ 36 mo → tier 1 floor = CNY 400. purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured: dict = {} p_def, p_resolve, p_create = _patch_feishu_calls(captured) with p_def, p_resolve, p_create: response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={ "asset_id": str(asset.id), "reason": "员工离职,按政策回购电脑", }, ) assert response.status_code == 200, response.text # Feishu applicant is the ADMIN (active), not the frozen leaver. assert captured["user_id"] == "feishu::admin@test.com" import json feishu_form = json.loads(captured["form"]) by_id = {w["id"]: w for w in feishu_form} # Amount forced to policy floor. assert Decimal(by_id["amount"]["value"]) == Decimal("400.00") # Last working day auto-filled from the leaver's HR record (the date # widget serializer expands the bare date to Feishu's datetime shape). assert by_id["resign_date"]["value"].startswith("2026-06-15") # Stored requester is the LEAVER (so approve-time disposal attributes # to them and the TOCTOU ownership check passes). row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id, ) ) req = row.scalar_one() assert req.requester_id == leaver_user.id assert req.request_type == "offboarding_buyback" resolved = req.detail["resolved_answers"]["amount"] assert Decimal(resolved["amount"]) == Decimal("400.00") @pytest.mark.asyncio async def test_admin_buyback_context_returns_live_widgets_and_scoped_hr_date( client: AsyncClient, admin_user, admin_headers: dict, leaver_user, db_session: AsyncSession, ): """The specialized preflight exposes the live required fields and HR date to asset admins without adding termination dates to generic assets.""" asset = AssetItem( asset_tag="BB-ONBEHALF-CONTEXT", category="laptop", status="held_by_leaver", assigned_to=leaver_user.id, location="Japan", purchase_cost=Decimal("5000"), currency="JPY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() with patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock(return_value={ "approval_name": "Laptop Purchase Request While Offboarding", "form": _OFFBOARDING_BUYBACK_FORM_WITH_LOCATION, }), ): response = await client.get( "/api/v1/approval/asset/requests/on-behalf/context", headers=admin_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 200, response.text payload = response.json()["data"] assert payload["last_working_day"] == "2026-06-15" location = next( widget for widget in payload["widgets"] if widget["id"] == "base_location" ) assert location["required"] is True assert location["options"][0] == {"value": "loc-jp", "text": "Japan"} @pytest.mark.asyncio async def test_admin_buyback_on_behalf_honors_currency( client: AsyncClient, admin_user, admin_headers: dict, leaver_user, db_session: AsyncSession, ): """Admin-selected display currency wins; amount stays policy-driven.""" asset = AssetItem( asset_tag="BB-ONBEHALF-CUR", category="laptop", status="held_by_leaver", assigned_to=leaver_user.id, serial_number="SN-ONBEHALF-CUR", purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured: dict = {} p_def, p_resolve, p_create = _patch_feishu_calls(captured) with p_def, p_resolve, p_create: response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={"asset_id": str(asset.id), "currency": "USD"}, ) assert response.status_code == 200, response.text row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id, ) ) req = row.scalar_one() resolved = req.detail["resolved_answers"]["amount"] assert resolved["currency"] == "USD" # USD tier-1 floor. assert Decimal(resolved["amount"]) == Decimal("55.00") # The non-default currency also reaches Feishu, as a SIBLING key on # the amount form item, so the approval card is labelled "USD" # instead of the currencyRange[0] default (CNY). ``value`` stays a # plain numeric string — an object there is the shape Feishu rejects # with 1395006. See _amount_wire_currency. feishu_form = json.loads(captured["form"]) amount_item = {w["id"]: w for w in feishu_form}["amount"] assert not isinstance(amount_item["value"], (dict, list)), amount_item assert amount_item["currency"] == "USD" assert Decimal(amount_item["value"]) == Decimal("55.00") @pytest.mark.asyncio async def test_admin_buyback_on_behalf_japan_sends_jpy_to_feishu( client: AsyncClient, admin_user, admin_headers: dict, leaver_user, db_session: AsyncSession, ): """Regression for the 2026-08-03 report: a Japan leaver's buyback was computed in JPY but shipped to Feishu as a bare number, so the multi-currency 金额 widget fell back to currencyRange[0] (CNY) and the approval card showed "10,400 CNY". The currency must ride along as a sibling ``currency`` key on the amount form item so the card reads JPY — and NOT inside ``value``, which is the shape Feishu rejects with 1395006 (the 2026-08-13 USD 代提回购 failure).""" asset = AssetItem( asset_tag="BB-ONBEHALF-JP", category="laptop", status="held_by_leaver", assigned_to=leaver_user.id, serial_number="SN-ONBEHALF-JP", # ~CNY 10 000-equivalent → tier 2 → JPY floor 10 400. purchase_cost=Decimal("10000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured: dict = {} p_def, p_resolve, p_create = _patch_feishu_calls(captured) with p_def, p_resolve, p_create: response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={"asset_id": str(asset.id), "currency": "JPY"}, ) assert response.status_code == 200, response.text feishu_form = json.loads(captured["form"]) amount_item = {w["id"]: w for w in feishu_form}["amount"] assert not isinstance(amount_item["value"], (dict, list)), ( "an object in ``value`` is the 1395006 shape — the currency " f"belongs on a sibling key, got {amount_item!r}" ) assert amount_item["currency"] == "JPY" assert Decimal(amount_item["value"]) == Decimal("10400") row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id, ) ) req = row.scalar_one() resolved = req.detail["resolved_answers"]["amount"] assert resolved["currency"] == "JPY" assert Decimal(resolved["amount"]) == Decimal("10400") @pytest.mark.asyncio async def test_employee_cannot_submit_on_behalf( client: AsyncClient, employee_user, employee_headers: dict, leaver_user, db_session: AsyncSession, ): """A plain employee cannot use the on-behalf endpoint (asset-admin only).""" asset = AssetItem( asset_tag="BB-ONBEHALF-403", category="laptop", status="held_by_leaver", assigned_to=leaver_user.id, purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=employee_headers, json={"asset_id": str(asset.id)}, ) assert response.status_code == 403, response.text context_response = await client.get( "/api/v1/approval/asset/requests/on-behalf/context", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert context_response.status_code == 403, context_response.text @pytest.mark.asyncio async def test_on_behalf_rejects_asset_with_no_holder( client: AsyncClient, admin_user, admin_headers: dict, db_session: AsyncSession, ): """An in-stock asset has no leaver to buy it back — reject with a clear code rather than minting an orphaned approval.""" asset = AssetItem( asset_tag="BB-ONBEHALF-NOHOLDER", category="laptop", status="idle", purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={"asset_id": str(asset.id)}, ) assert response.status_code == 400, response.text assert ( response.json()["errors"][0]["code"] == "BUYBACK_ON_BEHALF_NO_HOLDER" ) @pytest.mark.asyncio async def test_on_behalf_maps_required_location_option_from_asset( client: AsyncClient, admin_user, admin_headers: dict, leaver_user, db_session: AsyncSession, ): """Production regression: Base location selection is a required constrained widget. The server must map the asset's human-readable location to Feishu's canonical option value before validation.""" asset = AssetItem( asset_tag="BB-ONBEHALF-LOCATION", category="laptop", status="held_by_leaver", assigned_to=leaver_user.id, serial_number="SN-ONBEHALF-LOCATION", location="japan", purchase_cost=Decimal("5000"), currency="JPY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured: dict = {} patches = _patch_feishu_calls( captured, form=_OFFBOARDING_BUYBACK_FORM_WITH_LOCATION, ) with patches[0], patches[1], patches[2]: response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={"asset_id": str(asset.id)}, ) assert response.status_code == 200, response.text import json by_id = {item["id"]: item for item in json.loads(captured["form"])} assert by_id["base_location"]["value"] == "loc-jp" @pytest.mark.asyncio async def test_on_behalf_accepts_explicit_location_and_date_fallback( client: AsyncClient, admin_user, admin_headers: dict, leaver_user, db_session: AsyncSession, ): """When asset/HR data cannot supply required approval fields, the specialized admin form may provide them; Feishu option validation still applies and the HR date remains authoritative whenever it exists.""" leaver_user.termination_date = None asset = AssetItem( asset_tag="BB-ONBEHALF-FALLBACK", category="laptop", status="held_by_leaver", assigned_to=leaver_user.id, serial_number="SN-ONBEHALF-FALLBACK", location="Tokyo Office", purchase_cost=Decimal("5000"), currency="JPY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured: dict = {} patches = _patch_feishu_calls( captured, form=_OFFBOARDING_BUYBACK_FORM_WITH_LOCATION, ) with patches[0], patches[1], patches[2]: response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={ "asset_id": str(asset.id), "base_location": "Japan", "last_working_day": "2026-06-15", }, ) assert response.status_code == 200, response.text import json by_id = {item["id"]: item for item in json.loads(captured["form"])} assert by_id["base_location"]["value"] == "loc-jp" assert by_id["resign_date"]["value"].startswith("2026-06-15") @pytest.mark.asyncio async def test_on_behalf_rejects_non_leaver_asset( client: AsyncClient, admin_user, admin_headers: dict, leaver_user, db_session: AsyncSession, ): asset = AssetItem( asset_tag="BB-ONBEHALF-ACTIVE", category="laptop", status="claimed", assigned_to=leaver_user.id, serial_number="SN-ONBEHALF-ACTIVE", purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={"asset_id": str(asset.id)}, ) assert response.status_code == 400, response.text assert response.json()["errors"][0]["code"] == "ASSET_NOT_HELD_BY_LEAVER" @pytest.mark.asyncio async def test_on_behalf_rejects_active_holder_even_if_status_is_misclassified( client: AsyncClient, admin_user, admin_headers: dict, employee_user, db_session: AsyncSession, ): active_holder, _ = employee_user asset = AssetItem( asset_tag="BB-ONBEHALF-ACTIVE-HOLDER", category="laptop", status="held_by_leaver", assigned_to=active_holder.id, serial_number="SN-ONBEHALF-ACTIVE-HOLDER", purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={"asset_id": str(asset.id)}, ) assert response.status_code == 400, response.text assert ( response.json()["errors"][0]["code"] == "BUYBACK_ON_BEHALF_HOLDER_NOT_LEAVER" ) @pytest.mark.asyncio async def test_on_behalf_uses_current_borrower_as_beneficiary( client: AsyncClient, admin_user, admin_headers: dict, employee_user, leaver_user, db_session: AsyncSession, ): """A borrowed asset preserves the original owner in assigned_to; the borrower is the current physical holder and therefore the buyback subject.""" owner, _ = employee_user asset = AssetItem( asset_tag="BB-ONBEHALF-BORROWER", category="laptop", status="held_by_leaver", assigned_to=owner.id, borrowed_by=leaver_user.id, serial_number="SN-ONBEHALF-BORROWER", purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured: dict = {} patches = _patch_feishu_calls(captured) with patches[0], patches[1], patches[2]: response = await client.post( "/api/v1/approval/asset/requests/on-behalf", headers=admin_headers, json={"asset_id": str(asset.id)}, ) assert response.status_code == 200, response.text row = await db_session.scalar( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id, ) ) assert row is not None assert row.requester_id == leaver_user.id