from __future__ import annotations from collections.abc import Generator from datetime import UTC, datetime, timedelta import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool import app.models # noqa: F401 - 注册完整 metadata from app.api.deps import CurrentUserContext, get_current_user, get_db from app.api.v1.endpoints.commercial import router from app.api.v1.endpoints.commercial_billing import router as billing_router from app.db.base_class import Base @pytest.fixture() def http_context() -> Generator[ tuple[TestClient, dict[str, CurrentUserContext]], None, None, ]: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) app = FastAPI() app.include_router(router, prefix="/api/v1") app.include_router(billing_router, prefix="/api/v1") user_box = {"current": _user("platform-admin", "platform", is_admin=True)} def override_db() -> Generator[Session, None, None]: with factory() as db: yield db app.dependency_overrides[get_db] = override_db app.dependency_overrides[get_current_user] = lambda: user_box["current"] client = TestClient(app) client.headers.update({"X-Request-Id": "commercial-endpoint-test"}) try: yield client, user_box finally: client.close() app.dependency_overrides.clear() Base.metadata.drop_all(engine) engine.dispose() def test_commercial_http_admin_config_metering_and_tenant_read_scope( http_context: tuple[TestClient, dict[str, CurrentUserContext]], ) -> None: client, user_box = http_context now = datetime.now(UTC) plan_response = client.post( "/api/v1/commercial/admin/tenants/tenant-a/plans", json={ "plan_code": "enterprise", "name": "企业版", "pricing_model": "subscription", "billing_interval": "monthly", "currency": "CNY", "base_fee": "500.0000", "included_seats": 50, "effective_from": (now - timedelta(days=30)).isoformat(), }, ) assert plan_response.status_code == 201 plan = plan_response.json() assert plan["status"] == "draft" activate_response = client.post( f"/api/v1/commercial/admin/tenants/tenant-a/plans/{plan['id']}/activate", json={"expected_version": plan["version"], "reason": "启用企业套餐"}, ) assert activate_response.status_code == 200 assert activate_response.json()["plan"]["status"] == "active" subscription_response = client.post( "/api/v1/commercial/admin/tenants/tenant-a/subscriptions", json={ "subscription_key": "tenant-a-2026", "plan_id": plan["id"], "status": "active", "starts_at": (now - timedelta(days=5)).isoformat(), "current_period_start": (now - timedelta(days=1)).isoformat(), "current_period_end": (now + timedelta(days=29)).isoformat(), "seats": 20, }, ) assert subscription_response.status_code == 201 subscription = subscription_response.json() entitlement_response = client.put( "/api/v1/commercial/admin/tenants/tenant-a/entitlements", json={ "subscription_id": subscription["id"], "entitlement_key": "ocr", "metric_key": "ocr_pages", "entitlement_type": "metered", "unit": "page", "included_quantity": "100", "hard_limit_quantity": "120", "reset_interval": "monthly", "overage_policy": "block", "status": "active", "effective_from": (now - timedelta(days=5)).isoformat(), }, ) assert entitlement_response.status_code == 200 entitlement = entitlement_response.json() usage_payload = { "subscription_id": subscription["id"], "entitlement_id": entitlement["id"], "event_type": "usage", "quantity": "10", "occurred_at": now.isoformat(), "source_system": "ocr-runtime", "idempotency_key": "ocr-usage-001", } first_usage = client.post( "/api/v1/commercial/admin/tenants/tenant-a/usage-events", json=usage_payload, ) replay_usage = client.post( "/api/v1/commercial/admin/tenants/tenant-a/usage-events", json=usage_payload, ) assert first_usage.status_code == 200 assert first_usage.json()["created"] is True assert replay_usage.status_code == 200 assert replay_usage.json()["created"] is False conflict = client.post( "/api/v1/commercial/admin/tenants/tenant-a/usage-events", json={**usage_payload, "quantity": "11"}, ) assert conflict.status_code == 409 assert ( client.get("/api/v1/commercial/admin/tenants/tenant-a/plans").json()[0]["id"] == plan["id"] ) assert ( client.get("/api/v1/commercial/admin/tenants/tenant-a/subscriptions").json()[0]["id"] == subscription["id"] ) assert ( client.get("/api/v1/commercial/admin/tenants/tenant-a/entitlements").json()[0]["id"] == entitlement["id"] ) assert ( client.get("/api/v1/commercial/admin/tenants/tenant-a/usage-events").json()[0]["id"] == first_usage.json()["usage_event"]["id"] ) suspended = client.post( f"/api/v1/commercial/admin/tenants/tenant-a/subscriptions/{subscription['id']}/transition", json={ "expected_version": subscription["version"], "target_status": "suspended", "reason": "客户主动暂停商业服务", }, ) assert suspended.status_code == 200 assert suspended.json()["status"] == "suspended" resumed = client.post( f"/api/v1/commercial/admin/tenants/tenant-a/subscriptions/{subscription['id']}/activate", json={ "expected_version": suspended.json()["version"], "reason": "客户确认恢复商业服务", }, ) assert resumed.status_code == 200 assert resumed.json()["status"] == "active" periods = client.get("/api/v1/commercial/admin/tenants/tenant-a/billing-periods") assert periods.status_code == 200 assert periods.json()[0]["subscription_id"] == subscription["id"] assert periods.json()[0]["temporal_state"] == "current" audit_events = client.get("/api/v1/commercial/admin/tenants/tenant-a/admin-events") assert audit_events.status_code == 200 assert {item["action"] for item in audit_events.json()} >= { "plan_created", "subscription_created", "billing_period_created", } user_box["current"] = _user("finance-a", "tenant-a", roles=["finance"]) account = client.get("/api/v1/commercial/account") assert account.status_code == 200 assert account.json()["tenant_id"] == "tenant-a" assert account.json()["plan"]["id"] == plan["id"] assert account.json()["quotas"][0]["used_quantity"] == "10.000000" assert client.get("/api/v1/commercial/billing-periods").status_code == 200 assert ( client.get( "/api/v1/commercial/admin/tenants/tenant-a/admin-events" ).status_code == 403 ) assert ( client.post( "/api/v1/commercial/admin/tenants/tenant-a/cost-events", json=_cost_json(subscription["id"], now), ).status_code == 403 ) user_box["current"] = _user("finance-b", "tenant-b", roles=["finance"]) other_account = client.get("/api/v1/commercial/account") assert other_account.status_code == 200 assert other_account.json()["tenant_id"] == "tenant-b" assert other_account.json()["subscription"] is None assert other_account.json()["quotas"] == [] assert client.get("/api/v1/commercial/billing-periods").json() == [] user_box["current"] = _user("employee-a", "tenant-a") assert client.get("/api/v1/commercial/account").status_code == 403 assert client.get("/api/v1/commercial/billing-periods").status_code == 403 user_box["current"] = _user("manager-a", "tenant-a", roles=["manager"]) assert client.get("/api/v1/commercial/account").status_code == 403 assert ( client.post( "/api/v1/commercial/admin/tenants/tenant-a/plans", json={}, ).status_code == 403 ) def test_commercial_http_cost_is_admin_only_and_analytics_never_fakes_missing_value( http_context: tuple[TestClient, dict[str, CurrentUserContext]], ) -> None: client, user_box = http_context now = datetime.now(UTC) plan = client.post( "/api/v1/commercial/admin/tenants/tenant-a/plans", json={ "plan_code": "pilot", "name": "试点版", "pricing_model": "pilot", "billing_interval": "contract", "currency": "CNY", "base_fee": "1000", "effective_from": (now - timedelta(days=10)).isoformat(), }, ).json() client.post( f"/api/v1/commercial/admin/tenants/tenant-a/plans/{plan['id']}/activate", json={"expected_version": plan["version"], "reason": "启用试点套餐"}, ) subscription = client.post( "/api/v1/commercial/admin/tenants/tenant-a/subscriptions", json={ "subscription_key": "pilot-a", "plan_id": plan["id"], "starts_at": (now - timedelta(days=3)).isoformat(), "current_period_start": (now - timedelta(days=3)).isoformat(), "current_period_end": (now + timedelta(days=87)).isoformat(), "seats": 5, }, ).json() cost = client.post( "/api/v1/commercial/admin/tenants/tenant-a/cost-events", json=_cost_json(subscription["id"], now), ) replay = client.post( "/api/v1/commercial/admin/tenants/tenant-a/cost-events", json=_cost_json(subscription["id"], now), ) assert cost.status_code == 200 assert cost.json()["created"] is True assert cost.json()["cost_event"]["cost_amount"] == "25.0000" assert replay.status_code == 200 assert replay.json()["created"] is False analytics = client.get( "/api/v1/commercial/admin/tenants/tenant-a/analytics", params={ "start": (now - timedelta(days=5)).isoformat(), "end": (now + timedelta(days=1)).isoformat(), "as_of": (now + timedelta(hours=1)).isoformat(), }, ) assert analytics.status_code == 200 body = analytics.json() assert body["customer_charges"]["status"] == "partial" assert body["internal_costs"]["status"] == "available" assert body["contribution_margin"]["status"] == "partial" assert body["verified_cash_savings"]["status"] == "unavailable" assert body["verified_cash_savings"]["values"] == [] assert body["customer_roi"]["status"] == "unavailable" assert body["customer_labor_value"]["status"] == "unavailable" pricing = client.post( "/api/v1/commercial/admin/tenants/tenant-a/pricing-scenarios", json={ "start": (now - timedelta(days=5)).isoformat(), "end": (now + timedelta(days=1)).isoformat(), "as_of": (now + timedelta(hours=1)).isoformat(), "target_contribution_margin_rate": "0.65", "max_verified_savings_share": "0.25", }, ) assert pricing.status_code == 200 assert pricing.json()["recommended_model"] == "subscription" assert pricing.json()["scenarios"][0]["status"] == "cost_only" user_box["current"] = _user("finance-a", "tenant-a", roles=["finance"]) assert client.get("/api/v1/commercial/admin/tenants/tenant-a/analytics").status_code == 403 def _cost_json(subscription_id: str, now: datetime) -> dict[str, object]: return { "subscription_id": subscription_id, "event_type": "incurred", "cost_category": "ocr", "quantity": "100", "unit": "page", "unit_cost": "0.25", "original_currency": "CNY", "reporting_currency": "CNY", "fx_rate": "1", "allocation_key": "tenant-a:ocr", "occurred_at": now.isoformat(), "source_system": "ocr-provider", "idempotency_key": "ocr-cost-001", } def _user( username: str, tenant_id: str, *, roles: list[str] | None = None, is_admin: bool = False, ) -> CurrentUserContext: return CurrentUserContext( username=username, name=username, role_codes=list(roles or []), is_admin=is_admin, tenant_id=tenant_id, employee_id=username, )