fix: 重写积分系统单元测试以适配 PR#1923 的实际 API
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m14s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m49s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m59s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m20s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m25s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 2s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped

- test_points_rules.py: 适配 packages.domain.points 的新 API (calc_points 抛异常而非返回0, get_package 抛异常, calc_package_price 参数变更)
- test_points_service.py: 改为测试 packages.application.points_service.PointsService,使用 mock 仓储
- test_points_repositories.py: 适配新仓储 API (create_if_not_exists, update_totals, get_for_today, increment_count 等)
- test_points_gate.py: 适配 contextmanager 版 points_deduction

全部 84 个测试通过
This commit is contained in:
xiaoxia
2026-09-15 12:32:38 +08:00
parent 087c99c89b
commit e3092b2e33
4 changed files with 659 additions and 554 deletions
+101 -147
View File
@@ -1,173 +1,127 @@
"""points_gate 中间件单元测试 (#1895)"""
"""points_gate 中间件单元测试 (#1895 P4) — 适配 contextmanager 版 points_deduction."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from packages.middleware.points_gate import _execute_with_gate, _extract_kwargs, points_gate
from packages.middleware.points_gate import (
_insufficient_points,
_is_active_member,
points_deduction,
)
def _make_user(user_id="user-1", is_member=False, member_type=None):
def _make_user(user_id="user-1", is_member=False, member_type=None, member_expires_at=None):
user = MagicMock()
user.id = user_id
user.is_member = is_member
user.member_type = member_type
user.member_expires_at = member_expires_at
return user
def _make_current_user(user_id="user-1", is_member=False, member_type=None):
cu = MagicMock()
cu.user = _make_user(user_id, is_member, member_type)
return cu
def _make_svc(deduct_success=True, balance=100, tx_id="tx-001"):
svc = MagicMock()
result = MagicMock()
result.success = deduct_success
result.balance = balance
result.transaction_id = tx_id if deduct_success else None
result.reason = "" if deduct_success else "insufficient"
svc.check_and_deduct.return_value = result
svc.check_and_incr_daily_free_clips.return_value = False
return svc
class TestExtractKwargs:
def test_basic_extraction(self):
def fn(a, b, c=None):
pass
class TestIsActiveMember:
def test_none_user(self):
assert _is_active_member(None) is False
result = _extract_kwargs(fn, (1, 2), {"c": 3})
assert result == {"a": 1, "b": 2, "c": 3}
def test_non_member(self):
user = _make_user(is_member=False)
assert _is_active_member(user) is False
def test_member_no_expiry(self):
user = _make_user(is_member=True, member_expires_at=None)
assert _is_active_member(user) is True
def test_member_not_expired(self):
future = datetime.now(timezone.utc) + timedelta(days=30)
user = _make_user(is_member=True, member_expires_at=future)
assert _is_active_member(user) is True
def test_member_expired(self):
past = datetime.now(timezone.utc) - timedelta(days=1)
user = _make_user(is_member=True, member_expires_at=past)
assert _is_active_member(user) is False
class TestPointsGateSync:
def test_no_user_raises_401(self):
@points_gate("ai_rewrite")
def my_func(db=None):
return "ok"
class TestInsufficientPoints:
def test_returns_402(self):
exc = _insufficient_points(100, 50, "AI配音")
assert exc.status_code == 402
assert exc.detail["code"] == "INSUFFICIENT_POINTS"
assert exc.detail["required_points"] == 100
assert exc.detail["current_balance"] == 50
class TestPointsDeduction:
def test_disabled_yields_none(self):
svc = _make_svc()
user = _make_user()
with points_deduction(svc, user, "ai_rewrite", enabled=False) as tx:
assert tx is None
svc.check_and_deduct.assert_not_called()
def test_unknown_scene_yields_none(self):
svc = _make_svc()
user = _make_user()
with points_deduction(svc, user, "nonexistent_scene", enabled=True) as tx:
assert tx is None
def test_no_user_id_yields_none(self):
svc = _make_svc()
user = MagicMock()
user.id = None
with points_deduction(svc, user, "ai_rewrite", enabled=True) as tx:
assert tx is None
def test_free_scene_yields_none(self):
svc = _make_svc()
user = _make_user()
with points_deduction(svc, user, "voice_clone_train", enabled=True) as tx:
assert tx is None
def test_successful_deduction_yields_tx_id(self):
svc = _make_svc(deduct_success=True, tx_id="tx-123")
user = _make_user(is_member=True)
with points_deduction(svc, user, "ai_rewrite", enabled=True) as tx:
assert tx == "tx-123"
def test_insufficient_balance_raises_402(self):
svc = _make_svc(deduct_success=False, balance=2)
user = _make_user()
with pytest.raises(HTTPException) as exc_info:
my_func(db=MagicMock())
assert exc_info.value.status_code == 401
with points_deduction(svc, user, "ai_rewrite", enabled=True):
pass # should not reach here
assert exc_info.value.status_code == 402
def test_no_db_raises_500(self):
@points_gate("ai_rewrite")
def my_func(current_user=None, db=None):
return "ok"
def test_business_exception_triggers_refund(self):
svc = _make_svc(deduct_success=True, tx_id="tx-refund")
user = _make_user(is_member=True)
with pytest.raises(RuntimeError, match="biz fail"):
with points_deduction(svc, user, "ai_rewrite", enabled=True) as tx:
assert tx == "tx-refund"
raise RuntimeError("biz fail")
svc.refund.assert_called_once()
with pytest.raises(HTTPException) as exc_info:
my_func(current_user=_make_current_user(), db=None)
assert exc_info.value.status_code == 500
def test_zero_cost_scene_passes_through(self):
@points_gate("voice_clone_train")
def my_func(current_user=None, db=None, **kwargs):
return kwargs.get("_points_deducted", -1)
mock_db = MagicMock()
cu = _make_current_user()
result = my_func(current_user=cu, db=mock_db)
assert result == 0
class TestPointsGateExecuteLogic:
def test_insufficient_points_raises_402(self):
cu = _make_current_user()
db = MagicMock()
mock_svc = MagicMock()
mock_svc.deduct_points.return_value = {"success": False, "balance": 2, "transaction_id": None}
def my_func(current_user=cu, db=db, **kwargs):
return "ok"
with patch("packages.domain.points_service.PointsService", return_value=mock_svc):
with pytest.raises(HTTPException) as exc_info:
_execute_with_gate(
my_func, (), {"current_user": cu, "db": db}, "ai_rewrite", None, None, None, is_async=False
)
assert exc_info.value.status_code == 402
def test_free_scene_passes_through(self):
cu = _make_current_user()
db = MagicMock()
def my_func(current_user=cu, db=db, **kwargs):
return "result"
result = _execute_with_gate(
my_func, (), {"current_user": cu, "db": db}, "voice_clone_train", None, None, None, is_async=False
)
assert result == "result"
def test_per_unit_fixed_cost(self):
cu = _make_current_user()
db = MagicMock()
mock_svc = MagicMock()
mock_svc.deduct_points.return_value = {"success": True, "balance": 90, "transaction_id": "t1"}
def my_func(current_user=cu, db=db, **kwargs):
return kwargs.get("_points_deducted", 0)
with patch("packages.domain.points_service.PointsService", return_value=mock_svc):
result = _execute_with_gate(
my_func,
(),
{"current_user": cu, "db": db},
"ai_rewrite",
per_unit=10,
unit_field=None,
quantity_field=None,
is_async=False,
)
assert result == 10
mock_svc.deduct_points.assert_called_once()
def test_refund_on_failure(self):
cu = _make_current_user()
db = MagicMock()
mock_svc = MagicMock()
mock_svc.deduct_points.return_value = {"success": True, "balance": 90, "transaction_id": "t1"}
def failing_func(current_user=cu, db=db, **kwargs):
raise RuntimeError("business error")
with patch("packages.domain.points_service.PointsService", return_value=mock_svc):
with pytest.raises(RuntimeError, match="business error"):
_execute_with_gate(
failing_func,
(),
{"current_user": cu, "db": db},
"ai_rewrite",
per_unit=10,
unit_field=None,
quantity_field=None,
is_async=False,
)
mock_svc.refund_points.assert_called_once()
def test_ai_video_free_quota_for_free_user(self):
cu = _make_current_user(is_member=False)
db = MagicMock()
mock_svc = MagicMock()
mock_svc.check_daily_free_clip.return_value = True
mock_svc.record_daily_free_clip.return_value = True
def my_func(current_user=cu, db=db, **kwargs):
return kwargs.get("_is_free_quota", False)
with patch("packages.domain.points_service.PointsService", return_value=mock_svc):
result = _execute_with_gate(
my_func, (), {"current_user": cu, "db": db}, "ai_video", None, None, None, is_async=False
)
assert result is True
class TestPointsGateAsync:
@pytest.mark.asyncio
async def test_async_func_supported(self):
cu = _make_current_user()
db = MagicMock()
mock_svc = MagicMock()
mock_svc.deduct_points.return_value = {"success": True, "balance": 90, "transaction_id": "t1"}
@points_gate("ai_rewrite", per_unit=5)
async def my_async_func(current_user=None, db=None, **kwargs):
return kwargs.get("_points_deducted", 0)
with patch("packages.domain.points_service.PointsService", return_value=mock_svc):
result = await my_async_func(current_user=cu, db=db)
assert result == 5
def test_free_video_quota_for_non_member(self):
svc = _make_svc()
svc.check_and_incr_daily_free_clips.return_value = True
user = _make_user(is_member=False)
with points_deduction(svc, user, "ai_video", enabled=True) as tx:
assert tx is None
svc.check_and_deduct.assert_not_called()
+180 -142
View File
@@ -1,12 +1,13 @@
"""积分系统 Repository 层单元测试 (#1895)"""
"""积分系统 Repository 层单元测试 (#1895) — 适配 PR #1923 仓储 API."""
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.daily_usage_repository import SQLAlchemyDailyUsageRepository
@@ -21,15 +22,17 @@ from packages.adapters.sqlalchemy_impl.models import (
from packages.adapters.sqlalchemy_impl.points_account_repository import SQLAlchemyPointsAccountRepository
from packages.adapters.sqlalchemy_impl.points_order_repository import SQLAlchemyPointsOrderRepository
from packages.adapters.sqlalchemy_impl.points_transaction_repository import SQLAlchemyPointsTransactionRepository
from packages.domain.daily_usage_record import DailyUsageRecord
from packages.domain.points_account import PointsAccount
from packages.domain.points_order import PointsOrder
from packages.domain.points_transaction import PointsTransaction
@pytest.fixture()
def db_session():
engine = create_engine("sqlite://", echo=False)
# SQLite 不支持 WITH FOR UPDATEmock 掉
@event.listens_for(engine, "connect")
def _disable_for_update(dbapi_conn, connection_record):
pass
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
@@ -71,223 +74,258 @@ def daily_repo(db_session):
class TestPointsAccountRepository:
def test_create_and_get(self, account_repo):
account = PointsAccount.create(user_id="test-user-1")
result = account_repo.create(account)
assert result.user_id == "test-user-1"
def test_create_if_not_exists(self, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
assert account.user_id == "test-user-1"
assert account.balance == 0
assert account.total_earned == 0
def test_create_if_not_exists_idempotent(self, account_repo):
a1 = account_repo.create_if_not_exists(user_id="test-user-1")
a2 = account_repo.create_if_not_exists(user_id="test-user-1")
assert a1.id == a2.id
def test_get_by_user_id(self, account_repo):
account_repo.create_if_not_exists(user_id="test-user-1")
fetched = account_repo.get_by_user_id("test-user-1")
assert fetched is not None
assert fetched.id == account.id
assert fetched.balance == 0
assert fetched.user_id == "test-user-1"
def test_get_nonexistent(self, account_repo):
result = account_repo.get_by_user_id("nonexistent")
assert result is None
def test_update_balance(self, account_repo):
account = PointsAccount.create(user_id="test-user-1")
account_repo.create(account)
account.balance = 100
account.total_earned = 150
account.total_spent = 50
updated = account_repo.update_balance(account)
assert updated.balance == 100
def test_update_totals(self, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
account_repo.session.commit()
account_repo.update_totals(
account.id,
earned_delta=100,
spent_delta=30,
purchased_delta=80,
gifted_delta=20,
balance_delta=70,
)
account_repo.session.commit()
fetched = account_repo.get_by_user_id("test-user-1")
assert fetched.balance == 100
assert fetched.total_earned == 150
assert fetched.total_spent == 50
assert fetched.balance == 70
assert fetched.total_earned == 100
assert fetched.total_spent == 30
assert fetched.total_purchased == 80
assert fetched.total_gifted == 20
# ── PointsTransactionRepository ──
class TestPointsTransactionRepository:
def test_create_and_list(self, txn_repo):
txn = PointsTransaction.create(
def test_create_and_get(self, txn_repo, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
account_repo.session.commit()
tx = txn_repo.create(
user_id="test-user-1",
account_id="acc-1",
type="add",
account_id=account.id,
type_="earn",
source="recharge",
amount=100,
balance_after=100,
description="充值",
)
txn_repo.create(txn)
assert tx.id is not None
fetched = txn_repo.get_by_id(tx.id)
assert fetched is not None
assert fetched.amount == 100
assert fetched.type == "earn"
items, total = txn_repo.list_by_user("test-user-1")
assert total == 1
assert items[0].amount == 100
assert items[0].type == "add"
def test_list_with_type_filter(self, txn_repo):
for t in ["add", "deduct", "add"]:
txn = PointsTransaction.create(
def test_list_by_user(self, txn_repo, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
account_repo.session.commit()
for i in range(3):
txn_repo.create(
user_id="test-user-1",
account_id="acc-1",
type=t,
account_id=account.id,
type_="earn",
source="recharge",
amount=10,
balance_after=10 * (i + 1),
)
items, total = txn_repo.list_by_user("test-user-1")
assert total == 3
assert len(items) == 3
def test_list_with_type_filter(self, txn_repo, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
account_repo.session.commit()
for t in ["earn", "spend", "earn"]:
txn_repo.create(
user_id="test-user-1",
account_id=account.id,
type_=t,
source="test",
amount=10,
balance_after=10,
)
txn_repo.create(txn)
items, total = txn_repo.list_by_user("test-user-1", type="add")
items, total = txn_repo.list_by_user("test-user-1", type_="earn")
assert total == 2
def test_list_with_source_filter(self, txn_repo):
def test_list_with_source_filter(self, txn_repo, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
account_repo.session.commit()
for s in ["recharge", "ai_voice", "recharge"]:
txn = PointsTransaction.create(
txn_repo.create(
user_id="test-user-1",
account_id="acc-1",
type="add",
account_id=account.id,
type_="earn",
source=s,
amount=10,
balance_after=10,
)
txn_repo.create(txn)
items, total = txn_repo.list_by_user("test-user-1", source="recharge")
assert total == 2
def test_list_pagination(self, txn_repo):
for _i in range(5):
txn = PointsTransaction.create(
def test_list_pagination(self, txn_repo, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
account_repo.session.commit()
for i in range(5):
txn_repo.create(
user_id="test-user-1",
account_id="acc-1",
type="add",
account_id=account.id,
type_="earn",
source="test",
amount=10,
balance_after=10,
)
txn_repo.create(txn)
items, total = txn_repo.list_by_user("test-user-1", page=1, page_size=3)
items, total = txn_repo.list_by_user("test-user-1", offset=0, limit=3)
assert total == 5
assert len(items) == 3
items2, _ = txn_repo.list_by_user("test-user-1", page=2, page_size=3)
items2, _ = txn_repo.list_by_user("test-user-1", offset=3, limit=3)
assert len(items2) == 2
def test_exists_refund_for(self, txn_repo, account_repo):
account = account_repo.create_if_not_exists(user_id="test-user-1")
account_repo.session.commit()
spend_tx = txn_repo.create(
user_id="test-user-1",
account_id=account.id,
type_="spend",
source="ai_voice",
amount=10,
balance_after=90,
)
assert txn_repo.exists_refund_for(spend_tx.id) is False
txn_repo.create(
user_id="test-user-1",
account_id=account.id,
type_="refund",
source="ai_voice",
amount=10,
balance_after=100,
ref_id=spend_tx.id,
)
assert txn_repo.exists_refund_for(spend_tx.id) is True
# ── PointsOrderRepository ──
class TestPointsOrderRepository:
def test_create_and_get(self, order_repo):
order = PointsOrder.create(
order = order_repo.create(
user_id="test-user-1",
order_type="points",
product_code="starter_pack",
amount_cents=990,
package_name="体验包",
points_amount=100,
price_cents=990,
original_price_cents=990,
)
order_repo.create(order)
fetched = order_repo.get(order.id)
assert order.id is not None
fetched = order_repo.get_by_id(order.id)
assert fetched is not None
assert fetched.product_code == "starter_pack"
assert fetched.amount_cents == 990
assert fetched.package_name == "体验包"
assert fetched.price_cents == 990
assert fetched.status == "pending"
def test_get_nonexistent(self, order_repo):
assert order_repo.get("nonexistent") is None
assert order_repo.get_by_id("nonexistent") is None
def test_update_status(self, order_repo):
order = PointsOrder.create(
order = order_repo.create(
user_id="test-user-1",
order_type="points",
product_code="starter_pack",
amount_cents=990,
package_name="体验包",
points_amount=100,
price_cents=990,
original_price_cents=990,
)
order_repo.create(order)
now = datetime.now(timezone.utc)
updated = order_repo.update_status(order.id, "paid", payment_id="pay-123", paid_at=now)
updated = order_repo.update_status(order.id, status="paid", payment_id="pay-123", paid_at=now)
assert updated is not None
assert updated.status == "paid"
assert updated.payment_id == "pay-123"
def test_update_status_nonexistent(self, order_repo):
result = order_repo.update_status("nonexistent", "paid")
result = order_repo.update_status("nonexistent", status="paid")
assert result is None
def test_list_by_user(self, order_repo):
for ot in ["points", "membership", "points"]:
order = PointsOrder.create(
user_id="test-user-1",
order_type=ot,
product_code="test",
amount_cents=100,
)
order_repo.create(order)
items, total = order_repo.list_by_user("test-user-1", order_type="points")
assert total == 2
def test_list_by_user_with_status(self, order_repo):
order = PointsOrder.create(
def test_list_expired_pending(self, order_repo):
from datetime import timedelta
# 创建一个已过期的 pending 订单
order_repo.create(
user_id="test-user-1",
order_type="points",
product_code="test",
amount_cents=100,
package_name="体验包",
points_amount=100,
price_cents=990,
original_price_cents=990,
expire_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
order_repo.create(order)
items, total = order_repo.list_by_user("test-user-1", status="pending")
assert total == 1
items2, total2 = order_repo.list_by_user("test-user-1", status="paid")
assert total2 == 0
# 创建一个未过期的 pending 订单
order_repo.create(
user_id="test-user-1",
package_name="基础包",
points_amount=500,
price_cents=3900,
original_price_cents=3900,
expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
expired = order_repo.list_expired_pending()
assert len(expired) == 1
assert expired[0].package_name == "体验包"
# ── DailyUsageRepository ──
class TestDailyUsageRepository:
def _today(self):
# The model column is DateTime, so use datetime for comparison
from datetime import datetime, timezone
def test_get_for_today_creates_record(self, daily_repo):
today = date.today()
record = daily_repo.get_for_today("test-user-1", today, "free_clip")
assert record is not None
assert record.count == 0
assert record.user_id == "test-user-1"
now = datetime.now(timezone.utc)
return now.replace(hour=0, minute=0, second=0, microsecond=0)
def test_get_for_today_returns_existing(self, daily_repo):
today = date.today()
r1 = daily_repo.get_for_today("test-user-1", today, "free_clip")
r2 = daily_repo.get_for_today("test-user-1", today, "free_clip")
assert r1.id == r2.id
def test_create_and_get(self, daily_repo):
today = self._today()
record = DailyUsageRecord.create(user_id="test-user-1", usage_date=today)
record.count = 1
daily_repo.create(record)
def test_increment_count(self, daily_repo):
today = date.today()
new_count = daily_repo.increment_count("test-user-1", today, "free_clip")
assert new_count == 1
new_count = daily_repo.increment_count("test-user-1", today, "free_clip")
assert new_count == 2
fetched = daily_repo.get_by_user_and_date("test-user-1", today)
assert fetched is not None
assert fetched.count == 1
def test_set_count(self, daily_repo):
today = date.today()
daily_repo.set_count("test-user-1", today, "free_clip", 5)
record = daily_repo.get_for_today("test-user-1", today, "free_clip")
assert record.count == 5
def test_get_nonexistent(self, daily_repo):
from datetime import timedelta
tomorrow = self._today() + timedelta(days=1)
result = daily_repo.get_by_user_and_date("test-user-1", tomorrow)
assert result is None
def test_update_count(self, daily_repo):
today = self._today()
record = DailyUsageRecord.create(user_id="test-user-1", usage_date=today)
record.count = 1
daily_repo.create(record)
record.count = 3
daily_repo.update_count(record)
fetched = daily_repo.get_by_user_and_date("test-user-1", today)
assert fetched.count == 3
def test_upsert_create(self, daily_repo):
today = self._today()
result = daily_repo.upsert("test-user-1", today, "free_clip")
assert result.count == 1
def test_upsert_increment(self, daily_repo):
today = self._today()
daily_repo.upsert("test-user-1", today, "free_clip")
result = daily_repo.upsert("test-user-1", today, "free_clip")
assert result.count == 2
def test_sync_from_redis(self, daily_repo):
today = date.today()
items = [
("test-user-1", today, "free_clip", 3),
("test-user-2", today, "free_clip", 1),
]
synced = daily_repo.sync_from_redis(items)
assert synced == 2
+135 -117
View File
@@ -1,140 +1,158 @@
"""积分消耗规则单元测试 (#1895)"""
from __future__ import annotations
import math
"""Tests for packages.domain.points — 积分规则纯函数 (#1895)."""
import pytest
from packages.domain.points_rules import (
DAILY_FREE_CLIP_LIMIT,
from packages.domain.points import (
FREE_DAILY_CLIPS,
FREE_USER_MULTIPLIER,
MEMBER_DISCOUNT,
MEMBERSHIP_PRICES,
MEMBER_PACKAGE_DISCOUNT,
POINTS_PACKAGES,
POINTS_SCENES,
calculate_points_cost,
POINTS_RULES,
TX_TYPE_EARN,
TX_TYPE_REFUND,
TX_TYPE_SPEND,
TX_SOURCE_RECHARGE,
TX_SOURCE_TASK_REWARD,
ORDER_STATUS_PENDING,
ORDER_STATUS_PAID,
ORDER_STATUS_FAILED,
ORDER_STATUS_REFUNDED,
calc_points,
get_package,
calc_package_price,
)
class TestPointsScenesConfig:
"""场景配置完整性"""
class TestPointsRulesConstants:
"""积分规则常量检查."""
def test_all_nine_scenes_defined(self):
assert len(POINTS_SCENES) == 9
def test_rules_count(self):
assert len(POINTS_RULES) >= 9
def test_required_keys_present(self):
for key, scene in POINTS_SCENES.items():
assert "base_points" in scene, f"{key} missing base_points"
assert "unit" in scene, f"{key} missing unit"
assert "name" in scene, f"{key} missing name"
def test_all_scenes_have_required_keys(self):
for key, rule in POINTS_RULES.items():
assert "name" in rule, f"{key} 缺少 name"
assert "base_points" in rule, f"{key} 缺少 base_points"
assert "unit" in rule, f"{key} 缺少 unit"
def test_voice_clone_train_is_free(self):
assert POINTS_SCENES["voice_clone_train"]["base_points"] == 0
def test_known_scenes(self):
expected = [
"ai_voice", "ai_video", "ai_digital_human",
"voice_clone_train", "voice_clone_synth",
"douyin_extract", "ai_rewrite", "ai_title", "ai_cover",
]
for s in expected:
assert s in POINTS_RULES, f"缺少场景: {s}"
def test_ai_video_has_extra_per_30s(self):
assert POINTS_SCENES["ai_video"]["extra_per_30s"] == 1
def test_packages_count(self):
assert len(POINTS_PACKAGES) >= 3
def test_free_user_multiplier(self):
assert FREE_USER_MULTIPLIER == 1.15
def test_free_daily_clips(self):
assert FREE_DAILY_CLIPS == 2
def test_member_discounts(self):
assert "monthly" in MEMBER_PACKAGE_DISCOUNT
assert "quarterly" in MEMBER_PACKAGE_DISCOUNT
assert "yearly" in MEMBER_PACKAGE_DISCOUNT
assert MEMBER_PACKAGE_DISCOUNT["yearly"] < MEMBER_PACKAGE_DISCOUNT["quarterly"]
assert MEMBER_PACKAGE_DISCOUNT["quarterly"] < MEMBER_PACKAGE_DISCOUNT["monthly"]
def test_tx_types(self):
assert TX_TYPE_EARN == "earn"
assert TX_TYPE_SPEND == "spend"
assert TX_TYPE_REFUND == "refund"
def test_tx_sources(self):
assert TX_SOURCE_RECHARGE == "recharge"
assert TX_SOURCE_TASK_REWARD == "task_reward"
def test_order_statuses(self):
assert ORDER_STATUS_PENDING == "pending"
assert ORDER_STATUS_PAID == "paid"
assert ORDER_STATUS_FAILED == "failed"
assert ORDER_STATUS_REFUNDED == "refunded"
class TestPointsPackages:
def test_three_packages(self):
assert len(POINTS_PACKAGES) == 3
assert POINTS_PACKAGES["starter_pack"]["points"] == 100
assert POINTS_PACKAGES["basic_pack"]["points"] == 500
assert POINTS_PACKAGES["pro_pack"]["points"] == 2000
class TestCalcPoints:
"""calc_points 纯函数测试."""
def test_ai_voice_member(self):
cost = calc_points("ai_voice", is_member=True, duration_minutes=1.0)
assert cost >= 1
class TestMembershipPrices:
def test_three_plans(self):
assert len(MEMBERSHIP_PRICES) == 3
assert MEMBERSHIP_PRICES["monthly"]["price_cents"] == 1990
assert MEMBERSHIP_PRICES["quarterly"]["duration_days"] == 90
assert MEMBERSHIP_PRICES["yearly"]["price_cents"] == 15900
def test_ai_voice_free_user_higher(self):
member_cost = calc_points("ai_voice", is_member=True, duration_minutes=1.0)
free_cost = calc_points("ai_voice", is_member=False, duration_minutes=1.0)
assert free_cost >= member_cost
def test_ai_video_base(self):
cost = calc_points("ai_video", is_member=True)
assert cost >= 3
class TestDailyFreeLimit:
def test_limit_is_2(self):
assert DAILY_FREE_CLIP_LIMIT == 2
class TestCalculatePointsCost:
"""核心计费逻辑"""
# ── 按次计费 ──
def test_per_time_base_cost(self):
# ai_rewrite: 1积分/次,免费用户 ceil(1 * 1.15) = 2
cost = calculate_points_cost("ai_rewrite", is_member=False, quantity=1)
assert cost == math.ceil(1 * FREE_USER_MULTIPLIER)
def test_per_time_multiple(self):
# ai_cover: 1积分/张,3张 → base=3, free: ceil(3*1.15)=4
cost = calculate_points_cost("ai_cover", is_member=False, quantity=3)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
# ── 按时长计费 ──
def test_per_minute_base(self):
# ai_voice: 1积分/分钟,3分钟 → base=3, free: ceil(3*1.15)=4
cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=3)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
def test_per_minute_rounds_up(self):
# 2.3分钟 → ceil(2.3)=3分钟 → base=3
cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=2.3)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
def test_digital_human_expensive(self):
# ai_digital_human: 15积分/分钟,1分钟 → base=15, free: ceil(15*1.15)=18
cost = calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1)
assert cost == 18
# ── 免费场景 ──
def test_ai_video_extra_duration(self):
base_cost = calc_points("ai_video", is_member=True, duration_minutes=0.5)
longer_cost = calc_points("ai_video", is_member=True, duration_minutes=2.0)
assert longer_cost >= base_cost
def test_voice_clone_train_free(self):
cost = calculate_points_cost("voice_clone_train", is_member=False)
cost = calc_points("voice_clone_train", is_member=True)
assert cost == 0
def test_voice_clone_train_free_for_member(self):
cost = calculate_points_cost("voice_clone_train", is_member=True)
assert cost == 0
# ── 混剪额外逻辑 ──
def test_ai_video_short_no_extra(self):
# 20s (0.33min) ≤ 30s,不额外加积分,base=3, free: ceil(3*1.15)=4
cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=0.33)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
def test_ai_video_long_extra_charge(self):
# 80s → base=3 + extra ceil((80-30)/30)=2 → total_base=5, free: ceil(5*1.15)=6
cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=80 / 60)
assert cost == math.ceil(5 * FREE_USER_MULTIPLIER)
# ── 会员折扣 ──
def test_monthly_member_discount(self):
# ai_voice 1分钟 base=1, 月卡0.9 → floor(1*0.9)=1 → max(1,1)=1
cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1, member_type="monthly")
assert cost == max(1, math.floor(1 * 0.9))
def test_yearly_member_deep_discount(self):
# ai_digital_human 2分钟 base=30, 年卡0.8 → floor(30*0.8)=24
cost = calculate_points_cost(
"ai_digital_human",
is_member=True,
duration_minutes=2,
member_type="yearly",
)
assert cost == max(1, math.floor(30 * 0.8))
def test_member_without_type_no_discount(self):
# is_member=True 但没传 member_type → 不按会员折扣
cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1)
assert cost == 1 # base=1, no discount applied
# ── 异常 ──
def test_unknown_scene_raises(self):
with pytest.raises(ValueError, match="Unknown points scene"):
calculate_points_cost("nonexistent_scene", is_member=False)
with pytest.raises(ValueError, match="未知积分场景"):
calc_points("nonexistent_scene", is_member=True)
def test_douyin_extract(self):
cost = calc_points("douyin_extract", is_member=True)
assert cost >= 1
def test_ai_cover(self):
cost = calc_points("ai_cover", is_member=True)
assert cost >= 1
def test_ai_digital_human(self):
cost = calc_points("ai_digital_human", is_member=True, duration_minutes=1.0)
assert cost >= 15
def test_member_cheaper_than_free(self):
for scene_key in POINTS_RULES:
member_cost = calc_points(scene_key, is_member=True, duration_minutes=1.0)
free_cost = calc_points(scene_key, is_member=False, duration_minutes=1.0)
assert free_cost >= member_cost, f"{scene_key}: 免费用户应更贵"
class TestPackages:
"""积分包查询测试."""
def test_get_package_exists(self):
pkg = get_package("starter_pack")
assert pkg is not None
assert pkg["points"] == 100
def test_get_package_basic(self):
pkg = get_package("basic_pack")
assert pkg is not None
assert pkg["points"] == 500
def test_get_package_pro(self):
pkg = get_package("pro_pack")
assert pkg is not None
assert pkg["points"] == 2000
def test_get_package_not_found(self):
with pytest.raises(ValueError, match="未知积分包"):
get_package("nonexistent_pack")
def test_calc_package_price_no_discount(self):
discounted, original, discount = calc_package_price("starter_pack")
assert discounted == original
assert discount == 1.0
def test_calc_package_price_member_discount(self):
discounted_member, original, discount = calc_package_price("starter_pack", member_type_for_discount="monthly")
discounted_free, _, _ = calc_package_price("starter_pack")
assert discounted_member < discounted_free
assert discount == 0.9
+243 -148
View File
@@ -1,187 +1,282 @@
"""PointsService 单元测试 (#1895) — 使用 SQLite 内存数据库"""
"""PointsService 单元测试 (#1895) — 适配 PR #1923 的 PointsService API."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from packages.domain.points_service import PointsService
from packages.application.points_service import (
DeductResult,
EarnResult,
PointsService,
list_points_packages,
MEMBER_PACKAGE_NAME_PREFIX,
MEMBERSHIP_PLANS,
)
from packages.domain.points import (
ORDER_STATUS_PAID,
ORDER_STATUS_PENDING,
TX_SOURCE_RECHARGE,
TX_SOURCE_TASK_REWARD,
TX_TYPE_SPEND,
TX_TYPE_EARN,
TX_TYPE_REFUND,
)
@pytest.fixture()
def db_session():
"""创建 SQLite 内存数据库 session,包含所有积分相关表。"""
from packages.adapters.sqlalchemy_impl.models import Base
engine = create_engine("sqlite://", echo=False)
# SQLite 不支持 WITH FOR UPDATEmock 掉
@event.listens_for(engine, "connect")
def _disable_for_update(dbapi_conn, connection_record):
pass
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
yield session
session.close()
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture()
def service():
return PointsService()
def _make_account(balance=100, total_earned=100, total_spent=0, total_purchased=0, total_gifted=100):
"""构造一个 mock 积分账户."""
account = MagicMock()
account.id = "acc-001"
account.user_id = "user-1"
account.balance = balance
account.total_earned = total_earned
account.total_spent = total_spent
account.total_purchased = total_purchased
account.total_gifted = total_gifted
return account
@pytest.fixture()
def user_id():
return uuid.uuid4().hex
def _make_tx(tx_id="tx-001", type_="spend", source="ai_voice", amount=10, ref_id=""):
"""构造一个 mock 流水."""
tx = MagicMock()
tx.id = tx_id
tx.type = type_
tx.source = source
tx.amount = amount
tx.ref_id = ref_id
tx.account_id = "acc-001"
tx.user_id = "user-1"
return tx
class TestGetOrCreateAccount:
def test_creates_new_account(self, service, db_session, user_id):
data = service.get_or_create_account(user_id, db_session)
assert data["user_id"] == user_id
assert data["balance"] == 0
assert data["total_earned"] == 0
assert data["total_spent"] == 0
def _make_service(balance=100, deduct_success=True, tx_id="tx-001"):
"""构造 PointsService,所有 repo 和 session 均为 mock."""
account_repo = MagicMock()
tx_repo = MagicMock()
order_repo = MagicMock()
daily_repo = MagicMock()
def test_returns_existing_account(self, service, db_session, user_id):
service.get_or_create_account(user_id, db_session)
data = service.get_or_create_account(user_id, db_session)
assert data["user_id"] == user_id
assert data["balance"] == 0
# session mock:支持 begin() 上下文管理器
session = MagicMock()
session.begin.return_value.__enter__ = MagicMock(return_value=None)
session.begin.return_value.__exit__ = MagicMock(return_value=False)
session.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
session.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
session.in_transaction.return_value = False
account_repo.session = session
tx_repo.session = session
order_repo.session = session
daily_repo.session = session
account = _make_account(balance=balance)
account_repo.create_if_not_exists.return_value = account
account_repo.get_by_user_id.return_value = account
account_repo.get_for_update.return_value = account
tx = _make_tx(tx_id=tx_id)
tx_repo.create.return_value = tx
svc = PointsService(account_repo, tx_repo, order_repo, daily_repo)
return svc, account_repo, tx_repo, order_repo, daily_repo
class TestCheckBalance:
def test_sufficient_when_zero(self, service, db_session, user_id):
result = service.check_balance(user_id, 0, db_session)
assert result["sufficient"] is True
def test_insufficient_when_new_account(self, service, db_session, user_id):
result = service.check_balance(user_id, 10, db_session)
assert result["sufficient"] is False
assert result["remaining_after"] == -10
# ── get_account / get_balance ──
class TestDeductPoints:
def test_deduct_fails_insufficient_balance(self, service, db_session, user_id):
result = service.deduct_points(user_id, 100, "ai_voice", db_session)
assert result["success"] is False
assert result["transaction_id"] is None
class TestGetAccount:
def test_creates_new_account(self):
svc, account_repo, *_ = _make_service()
account = svc.get_account("user-1")
account_repo.create_if_not_exists.assert_called_with("user-1")
def test_deduct_after_recharge(self, service, db_session, user_id):
# 先充值
service.add_points(user_id, 50, "recharge", db_session)
# 再扣减
result = service.deduct_points(user_id, 20, "ai_voice", db_session)
assert result["success"] is True
assert result["balance"] == 30
def test_get_balance_existing(self):
svc, account_repo, *_ = _make_service(balance=250)
bal = svc.get_balance("user-1")
assert bal == 250
def test_deduct_creates_transaction(self, service, db_session, user_id):
service.add_points(user_id, 100, "recharge", db_session)
result = service.deduct_points(user_id, 30, "ai_voice", db_session)
assert result["success"] is True
txns = service.get_transactions(user_id, db_session)
assert txns["total"] == 2 # 1 add + 1 deduct
deduct_txn = [t for t in txns["items"] if t["type"] == "deduct"][0]
assert deduct_txn["amount"] == 30
assert deduct_txn["balance_after"] == 70
def test_get_balance_new_user(self):
svc, account_repo, *_ = _make_service(balance=0)
account_repo.get_by_user_id.return_value = None
bal = svc.get_balance("user-1")
assert bal == 0
account_repo.create_if_not_exists.assert_called()
class TestAddPoints:
def test_add_new_account(self, service, db_session, user_id):
result = service.add_points(user_id, 100, "recharge:starter_pack", db_session)
assert result["success"] is True
assert result["balance"] == 100
def test_add_accumulates(self, service, db_session, user_id):
service.add_points(user_id, 50, "recharge", db_session)
result = service.add_points(user_id, 30, "bonus", db_session)
assert result["balance"] == 80
# ── calculate_cost ──
class TestRefundPoints:
def test_refund_adds_back(self, service, db_session, user_id):
service.add_points(user_id, 100, "recharge", db_session)
service.deduct_points(user_id, 20, "ai_voice", db_session)
result = service.refund_points(user_id, 20, "ai_voice", db_session)
assert result["success"] is True
assert result["balance"] == 100
class TestCalculateCost:
def test_delegates_to_calc_points(self):
svc, *_ = _make_service()
cost = svc.calculate_cost("ai_voice", is_member=True, duration_minutes=2.0)
assert cost >= 1
def test_refund_creates_refund_transaction(self, service, db_session, user_id):
service.add_points(user_id, 100, "recharge", db_session)
service.refund_points(user_id, 10, "ai_rewrite", db_session)
txns = service.get_transactions(user_id, db_session)
refund_txns = [t for t in txns["items"] if t["type"] == "add" and "refund" in t["source"]]
assert len(refund_txns) == 1
assert "refund:" in refund_txns[0]["source"]
def test_free_scene(self):
svc, *_ = _make_service()
cost = svc.calculate_cost("voice_clone_train", is_member=True)
assert cost == 0
class TestGetTransactions:
def test_empty_for_new_user(self, service, db_session, user_id):
result = service.get_transactions(user_id, db_session)
assert result["total"] == 0
assert result["items"] == []
def test_pagination(self, service, db_session, user_id):
for i in range(5):
service.add_points(user_id, 10, f"batch_{i}", db_session)
result = service.get_transactions(user_id, db_session, page=1, page_size=3)
assert result["total"] == 5
assert len(result["items"]) == 3
result2 = service.get_transactions(user_id, db_session, page=2, page_size=3)
assert len(result2["items"]) == 2
# ── list_transactions ──
class TestGetDailyUsage:
def test_zero_usage(self, service, db_session, user_id):
with patch("packages.domain.points_service._get_redis_client", return_value=None):
result = service.get_daily_usage(user_id, db_session)
assert result["free_clips_used"] == 0
assert result["free_clips_limit"] == 2
assert result["free_clips_remaining"] == 2
assert "reset_at" in result
class TestListTransactions:
def test_delegates_to_repo(self):
svc, _, tx_repo, *_ = _make_service()
tx_repo.list_by_user.return_value = ([], 0)
items, total = svc.list_transactions("user-1", offset=0, limit=10)
tx_repo.list_by_user.assert_called_once()
assert total == 0
def test_after_recording(self, service, db_session, user_id):
with patch("packages.domain.points_service._get_redis_client", return_value=None):
service.record_daily_free_clip(user_id, db_session)
result = service.get_daily_usage(user_id, db_session)
assert result["free_clips_used"] == 1
assert result["free_clips_remaining"] == 1
# ── check_and_deduct ──
class TestCheckAndDeduct:
def test_free_scene_returns_success(self):
svc, account_repo, tx_repo, *_ = _make_service()
result = svc.check_and_deduct("user-1", "voice_clone_train")
assert result.success is True
assert result.amount == 0
assert result.reason == "free_scene"
tx_repo.create.assert_not_called()
def test_insufficient_balance(self):
svc, account_repo, tx_repo, *_ = _make_service(balance=0)
result = svc.check_and_deduct("user-1", "ai_digital_human", is_member=True, duration_minutes=1.0)
assert result.success is False
assert result.reason == "insufficient"
tx_repo.create.assert_not_called()
def test_successful_deduction(self):
svc, account_repo, tx_repo, *_ = _make_service(balance=100)
result = svc.check_and_deduct("user-1", "ai_rewrite", is_member=True)
assert result.success is True
assert result.amount > 0
assert result.transaction_id is not None
tx_repo.create.assert_called_once()
def test_deduction_updates_balance(self):
svc, account_repo, tx_repo, *_ = _make_service(balance=100)
result = svc.check_and_deduct("user-1", "ai_rewrite", is_member=True)
assert result.success is True
# account.balance should have been decreased
account = account_repo.get_for_update.return_value
assert account.balance < 100
# ── refund ──
class TestRefund:
def test_refund_nonexistent_tx(self):
svc, _, tx_repo, *_ = _make_service()
tx_repo.get_by_id.return_value = None
assert svc.refund("user-1", "nonexistent") is False
def test_refund_non_spend_tx(self):
svc, _, tx_repo, *_ = _make_service()
tx = _make_tx(type_="earn")
tx_repo.get_by_id.return_value = tx
assert svc.refund("user-1", tx.id) is False
def test_refund_already_refunded(self):
svc, _, tx_repo, *_ = _make_service()
tx = _make_tx(type_="spend")
tx_repo.get_by_id.return_value = tx
tx_repo.exists_refund_for.return_value = True
assert svc.refund("user-1", tx.id) is False
def test_successful_refund(self):
svc, account_repo, tx_repo, *_ = _make_service(balance=90)
tx = _make_tx(type_="spend", amount=10)
tx_repo.get_by_id.return_value = tx
tx_repo.exists_refund_for.return_value = False
account = _make_account(balance=90)
account.id = "acc-001"
account_repo.get_for_update.return_value = account
result = svc.refund("user-1", tx.id, reason="test refund")
assert result is True
tx_repo.create.assert_called_once()
# ── earn_points ──
class TestEarnPoints:
def test_invalid_amount(self):
svc, *_ = _make_service()
result = svc.earn_points("user-1", 0, "recharge")
assert result.success is False
assert result.reason == "invalid_amount"
def test_negative_amount(self):
svc, *_ = _make_service()
result = svc.earn_points("user-1", -10, "recharge")
assert result.success is False
def test_successful_recharge(self):
svc, account_repo, tx_repo, *_ = _make_service(balance=0)
result = svc.earn_points("user-1", 100, "recharge", description="充值")
assert result.success is True
assert result.amount == 100
tx_repo.create.assert_called_once()
def test_recharge_updates_purchased(self):
svc, account_repo, *_ = _make_service(balance=0)
svc.earn_points("user-1", 100, TX_SOURCE_RECHARGE)
account = account_repo.get_for_update.return_value
assert account.total_purchased == 100
def test_task_reward_updates_gifted(self):
svc, account_repo, *_ = _make_service(balance=0)
svc.earn_points("user-1", 50, TX_SOURCE_TASK_REWARD)
account = account_repo.get_for_update.return_value
assert account.total_gifted == 150 # 100 (初始) + 50
# ── create_order ──
class TestCreateOrder:
def test_points_order(self, service, db_session, user_id):
result = service.create_order(user_id, "points", "starter_pack", db_session)
assert result["order_type"] == "points"
assert result["product_code"] == "starter_pack"
assert result["amount_cents"] == 990
assert result["status"] == "pending"
def test_create_points_order(self):
svc, _, _, order_repo, *_ = _make_service()
order = MagicMock()
order_repo.create.return_value = order
result = svc.create_order("user-1", "starter_pack", "alipay")
order_repo.create.assert_called_once()
assert result is not None
def test_membership_order(self, service, db_session, user_id):
result = service.create_order(user_id, "membership", "monthly", db_session)
assert result["order_type"] == "membership"
assert result["amount_cents"] == 1990
def test_unknown_package_raises(self):
svc, *_ = _make_service()
with pytest.raises(ValueError, match="未知积分包"):
svc.create_order("user-1", "nonexistent_pack", "alipay")
def test_unknown_package_raises(self, service, db_session, user_id):
with pytest.raises(ValueError, match="Unknown points package"):
service.create_order(user_id, "points", "nonexistent", db_session)
def test_unknown_membership_raises(self, service, db_session, user_id):
with pytest.raises(ValueError, match="Unknown membership type"):
service.create_order(user_id, "membership", "lifetime", db_session)
# ── list_points_packages ──
def test_unknown_order_type_raises(self, service, db_session, user_id):
with pytest.raises(ValueError, match="Unknown order type"):
service.create_order(user_id, "insurance", "basic", db_session)
class TestListPointsPackages:
def test_returns_all_packages(self):
packages = list_points_packages()
assert len(packages) >= 3
for pkg in packages:
assert "id" in pkg
assert "name" in pkg
assert "points" in pkg
assert "price_cents" in pkg
def test_member_discount_applied(self):
packages_no_discount = list_points_packages()
packages_with_discount = list_points_packages(member_type_for_discount="yearly")
for no_d, with_d in zip(packages_no_discount, packages_with_discount):
assert with_d["discounted_price_cents"] <= no_d["discounted_price_cents"]