"""Unit tests for :mod:`app.approval.buyback_price`. Pure-function tests — no DB, no FastAPI. The endpoint integration test in :mod:`test_buyback_quote_api` covers the asset-loading, entity-currency-defaulting, and 422-on-missing-purchase-data paths. """ from datetime import date from decimal import Decimal import pytest from app.approval.buyback_price import ( DEPRECIATION_LIFETIME_MONTHS, FLOOR_TABLE, SUPPORTED_CURRENCIES, calculate_buyback, resolve_default_currency, ) # ─── Floor-table tier classification (≥ 36 months in service) ────── @pytest.mark.parametrize( "purchase_cost,expected_tier,expected_cny_floor", [ # Tier 1: < 8000 CNY (Decimal("100"), 1, Decimal("400")), (Decimal("5000"), 1, Decimal("400")), (Decimal("7999.99"), 1, Decimal("400")), # Tier 2: < 20000 CNY (policy wording "< 19999" → inclusive of # 19999 — we use < 20000 to match the natural reading) (Decimal("8000"), 2, Decimal("500")), (Decimal("15000"), 2, Decimal("500")), (Decimal("19999"), 2, Decimal("500")), # Tier 3: < 30000 CNY (Decimal("20000"), 3, Decimal("1000")), (Decimal("25000"), 3, Decimal("1000")), (Decimal("29999"), 3, Decimal("1000")), # Tier 4: ≥ 30000 CNY (Decimal("30000"), 4, Decimal("5000")), (Decimal("50000"), 4, Decimal("5000")), (Decimal("999999"), 4, Decimal("5000")), ], ) def test_floor_tier_classification( purchase_cost: Decimal, expected_tier: int, expected_cny_floor: Decimal, ): quote = calculate_buyback( purchase_cost=purchase_cost, purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), # ≥ 36 months in service target_currency="CNY", ) assert quote.mode == "floor" assert quote.tier == expected_tier assert quote.amount == expected_cny_floor.quantize(Decimal("0.01")) @pytest.mark.parametrize( "currency,expected_amount", [ ("CNY", Decimal("400")), ("JPY", Decimal("8000")), ("SGD", Decimal("75")), ("USD", Decimal("55")), ], ) def test_floor_tier1_per_currency_matches_policy( currency: str, expected_amount: Decimal, ): """Tier 1 floor values must match the IT-policy table verbatim.""" quote = calculate_buyback( purchase_cost=Decimal("5000"), purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency=currency, ) assert quote.mode == "floor" assert quote.tier == 1 if currency == "JPY": assert quote.amount == expected_amount.quantize(Decimal("1")) else: assert quote.amount == expected_amount.quantize(Decimal("0.01")) def test_floor_tier4_non_cny_currencies_are_derived_from_cny_5000(): """Tier 4 only specifies CNY 5 000 — non-CNY currencies come from the policy FX rates. Pinned to the floor-table constants so a future policy-table edit catches non-trivial currency drift.""" quote_jpy = calculate_buyback( purchase_cost=Decimal("40000"), purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="JPY", ) assert quote_jpy.tier == 4 assert quote_jpy.amount == FLOOR_TABLE[4]["JPY"].quantize(Decimal("1")) quote_usd = calculate_buyback( purchase_cost=Decimal("40000"), purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="USD", ) assert quote_usd.amount == FLOOR_TABLE[4]["USD"].quantize(Decimal("0.01")) def test_floor_quote_returns_prices_for_every_supported_currency(): """The quote.prices map drives the frontend currency switcher without an extra round-trip. Verify every supported currency has a populated entry.""" quote = calculate_buyback( purchase_cost=Decimal("5000"), purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="CNY", ) assert set(quote.prices.keys()) == set(SUPPORTED_CURRENCIES) for currency in SUPPORTED_CURRENCIES: assert quote.prices[currency] > 0 # ─── Linear depreciation (< 36 months) ───────────────────────────── def test_depreciation_12_months_returns_two_thirds_of_purchase_price(): """12 months in service on a 12 000 CNY laptop → 12000 / 36 * 24 = 8000 CNY.""" quote = calculate_buyback( purchase_cost=Decimal("12000"), purchase_currency="CNY", purchase_date=date(2025, 5, 12), today=date(2026, 5, 12), target_currency="CNY", ) assert quote.mode == "depreciation" assert quote.months_used == 12 assert quote.amount == Decimal("8000.00") def test_depreciation_exact_36_months_falls_back_to_floor(): """The cutover at the 36-month boundary lands on the floor table (consistent with "上面的情况都是超过了购买时间36个月以后").""" quote = calculate_buyback( purchase_cost=Decimal("5000"), purchase_currency="CNY", purchase_date=date(2023, 5, 12), today=date(2026, 5, 12), target_currency="CNY", ) assert quote.mode == "floor" assert quote.months_used == DEPRECIATION_LIFETIME_MONTHS assert quote.amount == Decimal("400.00") # tier 1 def test_depreciation_zero_months_returns_full_purchase_price(): """An asset purchased today depreciates by 0 — the formula purchase / 36 * 36 = purchase. Sanity check for the boundary.""" quote = calculate_buyback( purchase_cost=Decimal("12000"), purchase_currency="CNY", purchase_date=date(2026, 5, 12), today=date(2026, 5, 12), target_currency="CNY", ) assert quote.mode == "depreciation" assert quote.months_used == 0 assert quote.amount == Decimal("12000.00") def test_depreciation_cross_currency_via_policy_fx(): """Depreciation amount in the asset's purchase currency is converted to the requested display currency via the policy FX pivot through CNY. 12000 CNY laptop, 12 months in service → 8000 CNY → 8000 × 0.14 = 1120 USD (policy USD/CNY = 0.14).""" quote = calculate_buyback( purchase_cost=Decimal("12000"), purchase_currency="CNY", purchase_date=date(2025, 5, 12), today=date(2026, 5, 12), target_currency="USD", ) assert quote.mode == "depreciation" assert quote.amount == Decimal("1120.00") # ─── Default-currency resolution ─────────────────────────────────── def test_resolve_default_currency_uses_entity_currency_when_supported(): assert resolve_default_currency(entity_currency="SGD") == "SGD" assert resolve_default_currency(entity_currency="usd") == "USD" def test_resolve_default_currency_falls_back_to_cny_for_unknown_entity(): """An entity currency outside the supported set (e.g. THB / EUR) falls through to the asset currency and finally CNY.""" assert resolve_default_currency(entity_currency="THB") == "CNY" assert ( resolve_default_currency( entity_currency="THB", asset_currency="JPY", ) == "JPY" ) def test_resolve_default_currency_defaults_to_cny_when_nothing_known(): assert resolve_default_currency(entity_currency=None) == "CNY" assert resolve_default_currency( entity_currency=None, asset_currency=None, ) == "CNY" # ─── Error paths ─────────────────────────────────────────────────── def test_missing_purchase_cost_raises_with_stable_error_code(): with pytest.raises(ValueError, match=r"^MISSING_PURCHASE_COST:"): calculate_buyback( purchase_cost=None, purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), ) def test_missing_purchase_date_raises_with_stable_error_code(): with pytest.raises(ValueError, match=r"^MISSING_PURCHASE_DATE:"): calculate_buyback( purchase_cost=Decimal("5000"), purchase_currency="CNY", purchase_date=None, today=date(2026, 5, 12), ) def test_unsupported_target_currency_raises_with_stable_error_code(): with pytest.raises(ValueError, match=r"^UNSUPPORTED_CURRENCY:"): calculate_buyback( purchase_cost=Decimal("5000"), purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="EUR", ) def test_unsupported_purchase_currency_raises_instead_of_silent_fallback(): """An asset with currency outside the supported set (e.g. EUR, MYR, THB — the asset-intake form accepts these) used to silently fall through to "treat as CNY" in ``_to_cny``, which mis-tiers the asset by ~5× for a EUR/THB/MYR purchase (a EUR 25 000 laptop becomes tier 3 / CNY 1 000 floor when in CNY-equivalent it would be tier 4 / CNY 5 000). The contract is now: every asset whose buyback we quote MUST carry a currency in the supported set, OR have ``currency IS NULL`` (which the caller coerces to CNY).""" with pytest.raises(ValueError, match=r"^UNSUPPORTED_PURCHASE_CURRENCY:"): calculate_buyback( purchase_cost=Decimal("25000"), purchase_currency="EUR", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="CNY", ) def test_null_purchase_currency_defaults_to_cny(): """Legacy assets with ``asset.currency IS NULL`` quote as CNY — the calling service passes ``purchase_currency=None`` and the calculator coerces to CNY. This is the only legitimate path where an unspecified-currency input does NOT raise.""" quote = calculate_buyback( purchase_cost=Decimal("5000"), purchase_currency=None, purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="CNY", ) assert quote.tier == 1 assert quote.amount == Decimal("400.00") def test_future_purchase_date_raises_instead_of_quoting_full_sticker_price(): """A future ``purchase_date`` (pre-order paperwork, data-entry typo) used to silently quote the full sticker price — depreciation factor = 1.0 when months_used = 0. Now raises so finance / IT spot the bad asset row instead of approving an inflated buyback.""" with pytest.raises(ValueError, match=r"^FUTURE_PURCHASE_DATE:"): calculate_buyback( purchase_cost=Decimal("12000"), purchase_currency="CNY", purchase_date=date(2027, 1, 1), today=date(2026, 5, 12), target_currency="CNY", ) def test_case_insensitive_target_currency(): """Lowercase / mixed-case currency codes are normalised — the frontend Select uses uppercase but the endpoint accepts any case and so does the MCP tool (LLM clients are notoriously inconsistent about casing).""" quote = calculate_buyback( purchase_cost=Decimal("5000"), purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="usd", ) assert quote.currency == "USD" assert quote.amount == Decimal("55.00") @pytest.mark.parametrize( "purchase_cost,expected_tier", [ (Decimal("7999.99"), 1), (Decimal("8000"), 2), # tier 2 starts AT 8000 (Decimal("19999"), 2), # natural-band reading: still tier 2 (Decimal("19999.99"), 2), (Decimal("20000"), 3), # tier 3 starts AT 20000 (Decimal("29999"), 3), # natural-band reading: still tier 3 (Decimal("29999.99"), 3), (Decimal("30000"), 4), # tier 4 starts AT 30000 ], ) def test_tier_boundary_documented_interpretation( purchase_cost: Decimal, expected_tier: int, ): """Canonical reference for the tier-boundary interpretation. See the calculator module docstring for the IT-policy reading. If a boundary needs to shift, change ``TIER_BANDS`` AND this test together — anything else is a silent contract change.""" quote = calculate_buyback( purchase_cost=purchase_cost, purchase_currency="CNY", purchase_date=date(2020, 1, 1), today=date(2026, 5, 12), target_currency="CNY", ) assert quote.tier == expected_tier