Files
xiaoxia-saas/tests/unit/test_payment_callback.py
灵应 23ef50ccc0
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 1h0m49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1h0m49s
chore: 修复isort import排序
2026-07-09 11:53:39 +08:00

583 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
订阅支付回调单元测试
覆盖场景:
- 正确签名的回调处理(当前实现无签名验证,验证参数合法性)
- 缺失参数的回调被拒绝(422
- 重复回调的幂等性(mark_paid 对已支付账单返回 False
- 各种支付状态(成功处理流程)
- 不同套餐和计费周期
注:当前支付回调实现较简单(无签名验证,使用查询参数),
测试聚焦于回调处理的核心逻辑和边界情况。
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ──────────────────────────
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.api.routes.subscription import _get_plan_name, _get_plan_price, router
# ---------------------------------------------------------------------------
# 1. Mock Billing Repository
# ---------------------------------------------------------------------------
@dataclass
class MockBillingRecord:
id: str = ""
user_id: str = ""
plan_name: str = ""
amount: float = 0.0
billing_cycle: str = ""
status: str = "pending"
payment_method: str = ""
payment_id: str = ""
paid_at: datetime | None = None
created_at: datetime | None = None
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
if self.created_at is None:
self.created_at = datetime.now(timezone.utc)
class MockBillingRepository:
"""模拟的 Billing Repository,用于单元测试。"""
def __init__(self):
self.records: dict[str, MockBillingRecord] = {}
self.created_count = 0
self.mark_paid_count = 0
self.update_subscription_count = 0
self.updated_subscriptions: dict[str, dict] = {}
def create(self, record: dict) -> MockBillingRecord:
model = MockBillingRecord(**record)
self.records[model.id] = model
self.created_count += 1
return model
def find_by_user(self, user_id: str, limit: int = 50) -> list[MockBillingRecord]:
items = [r for r in self.records.values() if r.user_id == user_id]
items.sort(key=lambda r: r.created_at or datetime.min, reverse=True)
return items[:limit]
def find_by_id(self, record_id: str) -> MockBillingRecord | None:
return self.records.get(record_id)
def mark_paid(self, record_id: str, payment_method: str, payment_id: str) -> bool:
self.mark_paid_count += 1
model = self.records.get(record_id)
if model is None or model.status == "paid":
return False
model.status = "paid"
model.payment_method = payment_method
model.payment_id = payment_id
model.paid_at = datetime.now(timezone.utc)
return True
def update_subscription_on_payment(self, user_id: str, plan: str, expires_at: datetime) -> None:
self.update_subscription_count += 1
self.updated_subscriptions[user_id] = {
"plan": plan,
"expires_at": expires_at,
"status": "active",
}
# ---------------------------------------------------------------------------
# 2. Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_repo():
return MockBillingRepository()
def _make_client(mock_billing_repo: MockBillingRepository) -> TestClient:
"""创建带有 mock billing repository 的 TestClient。"""
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
# Mock SessionLocal 和 BillingRepository
mock_session = MagicMock()
with patch(
"packages.adapters.sqlalchemy_impl.session.SessionLocal",
return_value=mock_session,
):
with patch(
"packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository",
return_value=mock_billing_repo,
):
yield TestClient(test_app)
# ---------------------------------------------------------------------------
# 3. 支付成功回调测试
# ---------------------------------------------------------------------------
class TestPaymentCallbackSuccess:
"""支付成功回调测试。"""
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_monthly_pro_payment_success(self, MockSession, MockRepo):
"""Pro 套餐月付支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={
"user_id": "user-001",
"plan": "pro",
"billing_cycle": "monthly",
"amount": 299.0,
"payment_method": "alipay",
"payment_id": "pay_20240101_001",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert "支付成功" in data["message"]
assert "record_id" in data
# 验证账单创建
assert mock_repo.created_count == 1
# 验证标记支付
assert mock_repo.mark_paid_count == 1
# 验证订阅更新
assert mock_repo.update_subscription_count == 1
assert "user-001" in mock_repo.updated_subscriptions
assert mock_repo.updated_subscriptions["user-001"]["plan"] == "pro"
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_yearly_standard_payment_success(self, MockSession, MockRepo):
"""标准版年付支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={
"user_id": "user-002",
"plan": "standard",
"billing_cycle": "yearly",
"amount": 999.0,
"payment_method": "wechat",
"payment_id": "wx_20240101_002",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert mock_repo.updated_subscriptions["user-002"]["plan"] == "standard"
# 年付到期时间应为约 365 天后
expires_at = mock_repo.updated_subscriptions["user-002"]["expires_at"]
expected = datetime.now(timezone.utc) + timedelta(days=365)
assert abs((expires_at - expected).days) <= 1
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_enterprise_payment_success(self, MockSession, MockRepo):
"""企业版支付成功。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={
"user_id": "user-003",
"plan": "enterprise",
"billing_cycle": "monthly",
"amount": 999.0,
"payment_method": "bank_transfer",
"payment_id": "ent_20240101_003",
},
)
assert resp.status_code == 200
assert resp.json()["success"] is True
assert mock_repo.updated_subscriptions["user-003"]["plan"] == "enterprise"
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_default_payment_params(self, MockSession, MockRepo):
"""使用默认 payment_method 和空 payment_id。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={
"user_id": "user-004",
"plan": "standard",
"billing_cycle": "monthly",
"amount": 99.0,
},
)
assert resp.status_code == 200
assert resp.json()["success"] is True
# 默认 payment_method 应为 alipay
assert mock_repo.mark_paid_count == 1
# ---------------------------------------------------------------------------
# 4. 重复回调幂等性测试
# ---------------------------------------------------------------------------
class TestPaymentCallbackIdempotency:
"""支付回调幂等性测试。"""
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_duplicate_callback_creates_new_record(self, MockSession, MockRepo):
"""重复回调(当前实现每次创建新账单,无幂等保护)。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
params = {
"user_id": "user-idem-1",
"plan": "pro",
"billing_cycle": "monthly",
"amount": 299.0,
"payment_id": "pay_dup_001",
}
# 第一次回调
resp1 = client.post("/subscription/payment-callback", params=params)
assert resp1.status_code == 200
# 第二次回调(当前实现会创建新账单,不做幂等)
resp2 = client.post("/subscription/payment-callback", params=params)
assert resp2.status_code == 200
# 当前实现每次都会创建新账单
assert mock_repo.created_count == 2
def test_mark_paid_is_idempotent(self):
"""mark_paid 方法对已支付账单返回 False(幂等)。"""
repo = MockBillingRepository()
repo.create(
dict(
id="bill-001",
user_id="u1",
plan_name="Pro 专业版",
amount=299.0,
billing_cycle="monthly",
status="pending",
)
)
# 第一次标记为已支付
result1 = repo.mark_paid("bill-001", "alipay", "pay-001")
assert result1 is True
assert repo.records["bill-001"].status == "paid"
# 第二次标记(幂等,应返回 False)
result2 = repo.mark_paid("bill-001", "alipay", "pay-001")
assert result2 is False
assert repo.records["bill-001"].status == "paid"
# ---------------------------------------------------------------------------
# 5. 参数校验测试
# ---------------------------------------------------------------------------
class TestPaymentCallbackValidation:
"""支付回调参数校验测试。"""
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_missing_user_id_returns_422(self, MockSession, MockRepo):
"""缺少 user_id 参数返回 422。"""
MockRepo.return_value = MockBillingRepository()
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={"plan": "pro", "billing_cycle": "monthly", "amount": 299.0},
)
assert resp.status_code == 422
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_missing_plan_returns_422(self, MockSession, MockRepo):
"""缺少 plan 参数返回 422。"""
MockRepo.return_value = MockBillingRepository()
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={"user_id": "u1", "billing_cycle": "monthly", "amount": 299.0},
)
assert resp.status_code == 422
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_missing_amount_returns_422(self, MockSession, MockRepo):
"""缺少 amount 参数返回 422。"""
MockRepo.return_value = MockBillingRepository()
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={"user_id": "u1", "plan": "pro", "billing_cycle": "monthly"},
)
assert resp.status_code == 422
@patch("packages.adapters.sqlalchemy_impl.billing_repository.SQLAlchemyBillingRepository")
@patch("packages.adapters.sqlalchemy_impl.session.SessionLocal")
def test_negative_amount(self, MockSession, MockRepo):
"""负数金额(当前实现不校验,记录此行为)。"""
mock_repo = MockBillingRepository()
MockRepo.return_value = mock_repo
MockSession.return_value = MagicMock()
test_app = FastAPI()
test_app.include_router(router, prefix="/subscription")
client = TestClient(test_app)
resp = client.post(
"/subscription/payment-callback",
params={
"user_id": "u1",
"plan": "pro",
"billing_cycle": "monthly",
"amount": -100.0,
},
)
# 当前实现未校验金额正负
assert resp.status_code in (200, 400, 500)
# ---------------------------------------------------------------------------
# 6. 辅助函数测试
# ---------------------------------------------------------------------------
class TestHelperFunctions:
"""订阅辅助函数测试。"""
def test_get_plan_name_all_plans(self):
"""所有套餐名称映射正确。"""
assert _get_plan_name("free") == "体验版"
assert _get_plan_name("standard") == "标准版"
assert _get_plan_name("pro") == "专业版"
assert _get_plan_name("enterprise") == "企业版"
def test_get_plan_name_unknown(self):
"""未知套餐返回「未知套餐」。"""
assert _get_plan_name("unknown") == "未知套餐"
assert _get_plan_name("") == "未知套餐"
def test_get_plan_price_all_combinations(self):
"""所有套餐价格映射正确。"""
assert _get_plan_price("free", "monthly") == 0
assert _get_plan_price("free", "yearly") == 0
assert _get_plan_price("standard", "monthly") == 99
assert _get_plan_price("standard", "yearly") == 999
assert _get_plan_price("pro", "monthly") == 299
assert _get_plan_price("pro", "yearly") == 2999
assert _get_plan_price("enterprise", "monthly") == 999
assert _get_plan_price("enterprise", "yearly") == 9999
def test_get_plan_price_unknown(self):
"""未知组合返回 0。"""
assert _get_plan_price("unknown", "monthly") == 0
assert _get_plan_price("pro", "weekly") == 0
# ---------------------------------------------------------------------------
# 7. Mock Billing Repository 单元测试
# ---------------------------------------------------------------------------
class TestMockBillingRepository:
"""Billing Repository 行为单元测试。"""
def test_create_record(self):
"""创建账单记录。"""
repo = MockBillingRepository()
record = repo.create(
dict(
id="bill-001",
user_id="user-001",
plan_name="Pro 专业版",
amount=299.0,
billing_cycle="monthly",
status="pending",
)
)
assert record.id == "bill-001"
assert record.status == "pending"
assert repo.created_count == 1
def test_find_by_id(self):
"""按 ID 查询账单。"""
repo = MockBillingRepository()
repo.create(
dict(
id="bill-001",
user_id="user-1",
plan_name="Pro",
amount=299,
billing_cycle="monthly",
status="pending",
)
)
found = repo.find_by_id("bill-001")
assert found is not None
assert found.id == "bill-001"
not_found = repo.find_by_id("nonexistent")
assert not_found is None
def test_find_by_user(self):
"""按用户查询账单。"""
repo = MockBillingRepository()
repo.create(dict(id="b1", user_id="u1", plan_name="Pro", amount=299, billing_cycle="monthly", status="pending"))
repo.create(
dict(id="b2", user_id="u1", plan_name="Standard", amount=99, billing_cycle="monthly", status="pending")
)
repo.create(dict(id="b3", user_id="u2", plan_name="Pro", amount=299, billing_cycle="monthly", status="pending"))
user1_records = repo.find_by_user("u1")
assert len(user1_records) == 2
user2_records = repo.find_by_user("u2")
assert len(user2_records) == 1
def test_mark_paid_transitions_status(self):
"""mark_paid 正确转换状态。"""
repo = MockBillingRepository()
repo.create(
dict(
id="bill-001",
user_id="u1",
plan_name="Pro",
amount=299,
billing_cycle="monthly",
status="pending",
)
)
result = repo.mark_paid("bill-001", "alipay", "pay-001")
assert result is True
record = repo.find_by_id("bill-001")
assert record.status == "paid"
assert record.payment_method == "alipay"
assert record.payment_id == "pay-001"
assert record.paid_at is not None
def test_mark_paid_idempotent(self):
"""mark_paid 对已支付账单幂等。"""
repo = MockBillingRepository()
repo.create(
dict(
id="bill-001",
user_id="u1",
plan_name="Pro",
amount=299,
billing_cycle="monthly",
status="pending",
)
)
repo.mark_paid("bill-001", "alipay", "pay-001")
paid_at_first = repo.find_by_id("bill-001").paid_at
result = repo.mark_paid("bill-001", "alipay", "pay-001")
assert result is False
# paid_at 不应更新
assert repo.find_by_id("bill-001").paid_at == paid_at_first
def test_mark_paid_nonexistent_returns_false(self):
"""标记不存在的账单返回 False。"""
repo = MockBillingRepository()
result = repo.mark_paid("nonexistent", "alipay", "pay-001")
assert result is False
def test_update_subscription_on_payment(self):
"""支付成功后更新订阅。"""
repo = MockBillingRepository()
expires = datetime.now(timezone.utc) + timedelta(days=30)
repo.update_subscription_on_payment("user-001", "pro", expires)
assert repo.update_subscription_count == 1
assert "user-001" in repo.updated_subscriptions
sub = repo.updated_subscriptions["user-001"]
assert sub["plan"] == "pro"
assert sub["status"] == "active"
assert sub["expires_at"] == expires
if __name__ == "__main__":
pytest.main([__file__, "-v"])