"""Regression: ``GET /assets/software-catalog/{id}/plans`` and ``GET /assets/software-license-plans`` must return 200 for a regular employee opening the seat-request modal (price redacted to ``None``, not 500). PR #162 introduced finance-only price redaction in both list endpoints (``cols["price"] = None`` for non-finance / non-admin / non-auditor / non-management callers) but the response schema kept ``price: Decimal`` non-Optional. Pydantic's ``ValidationError`` then 500'd every regular employee opening the seat-request modal — surfaced as "加载坐席类型失败" toast on the frontend, with no plans rendered. Sister endpoint exercised: ``GET /assets/license-plans`` (cross- software view used by ``LicensePlanPage`` + ``SoftwareCostPage``). Same redaction shape so the schema fix needs to hold for both call sites. Sister fix in ``LicensePlanResponse.price: Decimal | None = None``. """ from __future__ import annotations import uuid from decimal import Decimal import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.models import AssetSoftwareCatalog, SoftwareLicensePlan async def _make_software_with_plan(db: AsyncSession) -> tuple[uuid.UUID, uuid.UUID]: sw = AssetSoftwareCatalog( id=uuid.uuid4(), name=f"OpenAI-{uuid.uuid4().hex[:6]}", asset_type="SaaS", status="active", offboarding_policy="auto_revoke", ) db.add(sw) await db.flush() plan = SoftwareLicensePlan( id=uuid.uuid4(), software_id=sw.id, name="Standard", price=Decimal("20.00"), billing_cycle="monthly", status="active", ) db.add(plan) await db.commit() return sw.id, plan.id @pytest.mark.asyncio async def test_per_software_plans_returns_200_with_redacted_price_for_employee( client: AsyncClient, db_session: AsyncSession, employee_user, ): sw_id, plan_id = await _make_software_with_plan(db_session) _, emp_token = employee_user resp = await client.get( f"/api/v1/assets/software-catalog/{sw_id}/plans", headers={"Authorization": f"Bearer {emp_token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True plans = body["data"] assert len(plans) == 1 # Redacted: employee can see the plan exists + name + cycle, but # not the per-seat cost. Frontend renders a price-less label in # this case (commit pairs schema fix with EmployeeRequestPage edit). assert plans[0]["price"] is None assert plans[0]["name"] == "Standard" assert plans[0]["billing_cycle"] == "monthly" @pytest.mark.asyncio async def test_per_software_plans_admin_sees_price( client: AsyncClient, db_session: AsyncSession, admin_user, ): sw_id, _ = await _make_software_with_plan(db_session) _, admin_token = admin_user resp = await client.get( f"/api/v1/assets/software-catalog/{sw_id}/plans", headers={"Authorization": f"Bearer {admin_token}"}, ) assert resp.status_code == 200 plans = resp.json()["data"] assert plans[0]["price"] == "20.00" @pytest.mark.asyncio async def test_all_plans_redacted_for_employee_returns_200( client: AsyncClient, db_session: AsyncSession, employee_user, ): """``list_all_license_plans`` (cross-software view) — same redaction posture as the per-software endpoint, exercised here so the schema fix is pinned for both call sites.""" await _make_software_with_plan(db_session) _, emp_token = employee_user resp = await client.get( "/api/v1/assets/license-plans", headers={"Authorization": f"Bearer {emp_token}"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["success"] is True assert all(p["price"] is None for p in body["data"])