Files
X-Financial/server/tests/test_commercial_endpoints.py

348 lines
13 KiB
Python
Raw Permalink Normal View History

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,
)