""" 订阅支付回调单元测试 覆盖场景: - 正确签名的回调处理(当前实现无签名验证,验证参数合法性) - 缺失参数的回调被拒绝(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"])