"""Integration tests for the offboarding-buyback quote endpoint. Covers GET /api/v1/approval/asset/buyback-quote — the surface the 离职回购 form calls when the requester picks an asset. Asserts the end-to-end happy path (asset bought 4+ years ago → tier floor in default currency), the entity-driven currency default, the currency-override query parameter, the missing-data 422 path, and the cross-employee-asset 403 path. """ from datetime import UTC, datetime, timedelta from decimal import Decimal import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.models import AssetItem, AssetLegalEntity @pytest.mark.asyncio async def test_buyback_quote_returns_floor_for_36_plus_month_old_asset( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """Asset bought ~6 years ago at CNY 5 000 → tier 1 (< 8 000), 36+ months in service, default currency CNY → CNY 400 floor.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-1", category="laptop", status="assigned", assigned_to=employee.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.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 200 data = response.json()["data"] assert data["mode"] == "floor" assert data["tier"] == 1 assert data["currency"] == "CNY" assert data["default_currency"] == "CNY" assert Decimal(data["amount"]) == Decimal("400.00") # The per-currency map drives the client-side currency switcher. assert set(data["prices"].keys()) == {"CNY", "USD", "JPY", "SGD"} assert Decimal(data["prices"]["JPY"]) == Decimal("8000") @pytest.mark.asyncio async def test_buyback_quote_default_currency_follows_purchase_entity( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """An asset whose purchase_entity resolves to an entity with ``currency=SGD`` should default the quote to SGD — no need for the employee to switch the dropdown.""" employee, _ = employee_user entity = AssetLegalEntity( name="PingCAP Singapore Pte Ltd", code="SG", currency="SGD", ) db_session.add(entity) asset = AssetItem( asset_tag="BB-SG", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), purchase_entity="PingCAP Singapore Pte Ltd", ) db_session.add(asset) await db_session.commit() response = await client.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 200 data = response.json()["data"] assert data["default_currency"] == "SGD" assert data["currency"] == "SGD" # Tier 1 / SGD floor per policy = 75 SGD. assert Decimal(data["amount"]) == Decimal("75.00") @pytest.mark.asyncio async def test_buyback_quote_registered_entity_currency_overrides_request_param( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """A registered receiving entity wins over a caller's currency parameter. This prevents the payment amount from disagreeing with the bank account the employee is instructed to pay. """ employee, _ = employee_user entity = AssetLegalEntity( name="PINGCAP PTE. LTD.", code="SG", currency="SGD", ) asset = AssetItem( asset_tag="BB-2", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), # Historic imports commonly omitted punctuation from a registered # legal-entity name. This is the original production failure shape. purchase_entity="PINGCAP PTE LTD", ) db_session.add_all([entity, asset]) await db_session.commit() response = await client.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id), "currency": "USD"}, ) assert response.status_code == 200 data = response.json()["data"] assert data["currency"] == "SGD" assert Decimal(data["amount"]) == Decimal("75.00") @pytest.mark.asyncio async def test_buyback_quote_depreciation_for_recently_purchased_asset( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """Asset bought roughly a year ago at 12 000 CNY — depreciation branch, ≈ 12000 / 36 × (36 - months_used) CNY. Asserts months_used is in the [10, 13] window (date math depends on test-run day).""" employee, _ = employee_user one_year_ago = datetime.now(UTC) - timedelta(days=365) asset = AssetItem( asset_tag="BB-FRESH", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("12000"), currency="CNY", purchase_date=one_year_ago, ) db_session.add(asset) await db_session.commit() response = await client.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 200 data = response.json()["data"] assert data["mode"] == "depreciation" # 365 days back lands at ~12 months, allowing for the rounding # rule in _months_between (day-of-month tie-break) — accept any # value in the natural window. assert 10 <= data["months_used"] <= 13 @pytest.mark.asyncio async def test_buyback_quote_returns_422_when_purchase_data_missing( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """A backfill-incomplete asset (no purchase_cost or no purchase_date) returns 422 with a stable error code the frontend can show in the help text.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-NOCOST", category="laptop", status="assigned", assigned_to=employee.id, # purchase_cost / purchase_date intentionally omitted ) db_session.add(asset) await db_session.commit() response = await client.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 422 body = response.json() assert body["errors"][0]["code"] == "MISSING_PURCHASE_COST" @pytest.mark.asyncio async def test_buyback_quote_rejects_strangers_assets( client: AsyncClient, admin_user, employee_user, employee_headers: dict, db_session: AsyncSession, ): """An employee may only quote buyback for assets they own / currently borrow — otherwise anyone can enumerate the org's purchase prices. Asset is assigned to the admin (separate user) so the employee request hits the 403 branch.""" admin, _ = admin_user asset = AssetItem( asset_tag="BB-STRANGER", category="laptop", status="assigned", assigned_to=admin.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.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 403 @pytest.mark.asyncio async def test_buyback_quote_admin_can_quote_any_asset( client: AsyncClient, admin_user, asset_admin_headers: dict, db_session: AsyncSession, ): """Asset / IT admins bypass the owner-or-borrower gate via ``can_manage_assets`` — IT walks leavers through buyback on a call and needs to be able to quote their device.""" admin, _ = admin_user asset = AssetItem( asset_tag="BB-ADMIN-QUOTE", category="laptop", status="assigned", assigned_to=admin.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.get( "/api/v1/approval/asset/buyback-quote", headers=asset_admin_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 200 assert Decimal(response.json()["data"]["amount"]) == Decimal("400.00") @pytest.mark.asyncio async def test_buyback_quote_rejects_soft_deleted_asset( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """Soft-deleted assets must 404, not silently quote. Without the ``deleted_at IS NULL`` filter in ``_get_asset``, approval submission and quote both touch deleted rows — and the auto-execute path would then mutate a deleted asset.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-DELETED", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), deleted_at=datetime(2026, 5, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() response = await client.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 404 @pytest.mark.asyncio async def test_buyback_quote_rejects_unsupported_purchase_currency( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """An asset whose recorded currency is EUR / MYR / THB (intake form accepts these but the buyback policy doesn't enumerate them) must 422 with a stable error code instead of silently mis-tiering.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-EUR", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("25000"), currency="EUR", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() response = await client.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 422 assert ( response.json()["errors"][0]["code"] == "UNSUPPORTED_PURCHASE_CURRENCY" ) @pytest.mark.asyncio async def test_buyback_quote_currency_param_is_case_insensitive( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """``?currency=usd`` and ``?currency=USD`` return the same quote — LLM clients hitting the MCP tool are inconsistent about casing and the endpoint must not surprise them with a 422.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-CASE", category="laptop", status="assigned", assigned_to=employee.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.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id), "currency": "usd"}, ) assert response.status_code == 200 body = response.json()["data"] assert body["currency"] == "USD" assert Decimal(body["amount"]) == Decimal("55.00") @pytest.mark.asyncio async def test_buyback_quote_rejects_future_purchase_date( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """An asset with ``purchase_date`` in the future is a data-entry error or pre-order paperwork — quoting the full sticker price would be a giveaway. 422 + stable code so the frontend shows the right hint.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-FUTURE", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("12000"), currency="CNY", purchase_date=datetime.now(UTC) + timedelta(days=365), ) db_session.add(asset) await db_session.commit() response = await client.get( "/api/v1/approval/asset/buyback-quote", headers=employee_headers, params={"asset_id": str(asset.id)}, ) assert response.status_code == 422 assert response.json()["errors"][0]["code"] == "FUTURE_PURCHASE_DATE"