Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
348 lines
13 KiB
Python
348 lines
13 KiB
Python
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,
|
|
)
|