67a1ed6430
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m3s
CI/CD Pipeline / Integration Tests (push) Successful in 3m7s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m40s
CI/CD Pipeline / Validate - Style (push) Successful in 3m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m17s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m6s
CI/CD Pipeline / Validate - Security (push) Successful in 7m14s
CI/CD Pipeline / Unit Tests (push) Successful in 9m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / Staging API Integration Tests (push) Successful in 35m54s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
187 lines
8.2 KiB
Python
187 lines
8.2 KiB
Python
"""#1894 旧档位归一化逻辑测试(覆盖 _build_subscription_info / change_plan / cancel 等分支)"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, replace
|
||
from datetime import UTC, datetime
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
from app.api.routes import subscription
|
||
from app.auth import AuthenticatedUser
|
||
from app.schemas.subscription import (
|
||
BillingCycle,
|
||
ChangePlanRequest,
|
||
MembershipType,
|
||
ToggleAutoRenewRequest,
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class _FakeUserModel:
|
||
id: str = "u-1234567890"
|
||
subscription_plan: str | None = MembershipType.FREE
|
||
subscription_status: str | None = "active"
|
||
subscription_expires_at: datetime | None = None
|
||
created_at: datetime | None = None
|
||
|
||
|
||
@pytest.fixture
|
||
def auth_user():
|
||
return AuthenticatedUser(user=_FakeUserModel())
|
||
|
||
|
||
class TestBuildSubscriptionInfoLegacy:
|
||
"""覆盖 _build_subscription_info 旧档位归一化(subscription.py 57-58 行)"""
|
||
|
||
def test_legacy_standard_plan_normalized_to_monthly(self, auth_user):
|
||
new_user = replace(auth_user.user, subscription_plan="standard")
|
||
auth_user = replace(auth_user, user=new_user)
|
||
|
||
info = subscription._build_subscription_info(auth_user)
|
||
assert info.plan_id == MembershipType.MONTHLY
|
||
assert info.plan_name == "月卡会员"
|
||
|
||
def test_legacy_pro_plan_normalized_to_monthly(self, auth_user):
|
||
new_user = replace(auth_user.user, subscription_plan="pro")
|
||
auth_user = replace(auth_user, user=new_user)
|
||
|
||
info = subscription._build_subscription_info(auth_user)
|
||
# 旧 pro/standard/enterprise 都归一化到 monthly(按代码逻辑 {standard,pro,enterprise} → monthly)
|
||
assert info.plan_id == MembershipType.MONTHLY
|
||
|
||
def test_legacy_enterprise_plan_normalized(self, auth_user):
|
||
new_user = replace(auth_user.user, subscription_plan="enterprise")
|
||
auth_user = replace(auth_user, user=new_user)
|
||
|
||
info = subscription._build_subscription_info(auth_user)
|
||
assert info.plan_id == MembershipType.MONTHLY
|
||
|
||
def test_no_expiry_gives_now_period(self, auth_user):
|
||
"""无过期时间时 period_start 和 period_end 都为 now(覆盖 else 分支 55-56 行)"""
|
||
new_user = replace(auth_user.user, subscription_expires_at=None)
|
||
auth_user = replace(auth_user, user=new_user)
|
||
|
||
info = subscription._build_subscription_info(auth_user)
|
||
# 两个时间都应非空且接近当前时间
|
||
assert info.current_period_start
|
||
assert info.current_period_end
|
||
|
||
def test_free_user_billing_cycle_defaults_to_monthly(self, auth_user):
|
||
"""免费用户 billing_cycle 回落到 monthly(覆盖第 64 行 !=FREE 判定 else 分支)"""
|
||
new_user = replace(auth_user.user, subscription_plan=MembershipType.FREE)
|
||
auth_user = replace(auth_user, user=new_user)
|
||
|
||
info = subscription._build_subscription_info(auth_user)
|
||
assert info.billing_cycle == BillingCycle.MONTHLY
|
||
assert info.amount == 0
|
||
|
||
def test_yearly_user_passthrough(self, auth_user):
|
||
"""yearly 用户档位直接透传"""
|
||
new_user = replace(auth_user.user, subscription_plan=MembershipType.YEARLY)
|
||
auth_user = replace(auth_user, user=new_user)
|
||
|
||
info = subscription._build_subscription_info(auth_user)
|
||
assert info.plan_id == MembershipType.YEARLY
|
||
assert info.plan_name == "年卡会员"
|
||
|
||
|
||
class TestChangePlanValidation:
|
||
"""覆盖 change_plan 入参校验 / 同档位提示 / 旧档位归一化"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invalid_plan_returns_400(self, auth_user):
|
||
"""无效 plan_id → 400(覆盖 169 行)"""
|
||
from fastapi import HTTPException
|
||
req = ChangePlanRequest(target_plan_id="totally_bogus_plan", billing_cycle=BillingCycle.MONTHLY)
|
||
with pytest.raises(HTTPException) as exc:
|
||
await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
|
||
assert exc.value.status_code == 400
|
||
assert "无效" in exc.value.detail
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_invalid_billing_cycle_returns_400(self, auth_user):
|
||
"""无效 billing_cycle → 400(覆盖 176/178-179 行)"""
|
||
from fastapi import HTTPException
|
||
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle="bogus_cycle")
|
||
with pytest.raises(HTTPException) as exc:
|
||
await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
|
||
assert exc.value.status_code == 400
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_same_plan_returns_message(self, auth_user):
|
||
"""同档位变更 → 返回提示(覆盖 185 行分支)"""
|
||
new_user = replace(auth_user.user, subscription_plan=MembershipType.MONTHLY)
|
||
auth_user = replace(auth_user, user=new_user)
|
||
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle=BillingCycle.MONTHLY)
|
||
resp = await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
|
||
assert resp.success is False
|
||
assert "已经是" in resp.message
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_legacy_plan_normalized_for_same_plan_check(self, auth_user):
|
||
"""旧档位用户升级到 monthly → 应先归一化 current_plan 到 monthly,再判定为'同档位'"""
|
||
new_user = replace(auth_user.user, subscription_plan="standard")
|
||
auth_user = replace(auth_user, user=new_user)
|
||
# legacy → monthly
|
||
req = ChangePlanRequest(target_plan_id=MembershipType.MONTHLY, billing_cycle=BillingCycle.MONTHLY)
|
||
resp = await subscription.change_plan(request=req, current_user=auth_user, user_repository=mock.MagicMock())
|
||
# standard 归一化到 monthly,所以 target monthly == current monthly → same plan
|
||
assert resp.success is False
|
||
|
||
|
||
class TestCancelSubscription:
|
||
"""覆盖 cancel_subscription 分支"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_free_user_returns_400(self, auth_user):
|
||
"""免费用户取消订阅 → 400(覆盖 252 行)"""
|
||
from fastapi import HTTPException
|
||
new_user = replace(auth_user.user, subscription_plan=MembershipType.FREE)
|
||
auth_user = replace(auth_user, user=new_user)
|
||
|
||
with pytest.raises(HTTPException) as exc:
|
||
await subscription.cancel_subscription(current_user=auth_user, user_repository=mock.MagicMock())
|
||
assert exc.value.status_code == 400
|
||
assert "免费" in exc.value.detail
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancel_paid_user_marks_cancelled(self, auth_user):
|
||
"""付费用户取消订阅 → save 被调用且 subscription_status='cancelled'"""
|
||
new_user = replace(auth_user.user, subscription_plan=MembershipType.MONTHLY)
|
||
auth_user = replace(auth_user, user=new_user)
|
||
repo = mock.MagicMock()
|
||
resp = await subscription.cancel_subscription(current_user=auth_user, user_repository=repo)
|
||
assert resp.success is True
|
||
repo.save.assert_called_once()
|
||
saved_user = repo.save.call_args[0][0]
|
||
assert saved_user.subscription_status == "cancelled"
|
||
|
||
|
||
class TestToggleAutoRenew:
|
||
"""覆盖 toggle_auto_renew"""
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("enabled,msg", [(True, "已开启"), (False, "已关闭")])
|
||
async def test_toggle_returns_message(self, auth_user, enabled, msg):
|
||
req = ToggleAutoRenewRequest(enabled=enabled)
|
||
resp = await subscription.toggle_auto_renew(request=req, current_user=auth_user)
|
||
assert resp.success is True
|
||
assert msg in resp.message
|
||
|
||
|
||
class TestBuildSubscriptionInfoEdgeCases:
|
||
"""覆盖 _build_subscription_info 的边缘分支"""
|
||
|
||
def test_created_at_none_uses_now(self, auth_user):
|
||
"""user.created_at 为 None 时,created_at 字段回落到 now.isoformat(覆盖 69 行)"""
|
||
new_user = replace(auth_user.user, created_at=None, subscription_plan=MembershipType.MONTHLY)
|
||
auth_user2 = replace(auth_user, user=new_user)
|
||
info = subscription._build_subscription_info(auth_user2)
|
||
assert info.created_at # 非空
|
||
# 应为 ISO 格式字符串
|
||
from datetime import datetime
|
||
|
||
# 能解析即通过
|
||
datetime.fromisoformat(info.created_at)
|