feat: Add scheduler for daily user credits recharge

This commit is contained in:
JSC
2025-08-11 00:30:29 +02:00
parent bdeb00d562
commit d1bf2fe0a4
8 changed files with 698 additions and 109 deletions

View File

@@ -8,6 +8,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.credit_action import CreditActionType
from app.models.credit_transaction import CreditTransaction
from app.models.plan import Plan
from app.models.user import User
from app.services.credit import CreditService, InsufficientCreditsError
@@ -38,6 +39,18 @@ class TestCreditService:
plan_id=1,
)
@pytest.fixture
def sample_plan(self):
"""Create a sample plan for testing."""
return Plan(
id=1,
code="basic",
name="Basic Plan",
description="Basic plan with limited credits",
credits=50,
max_credits=100,
)
@pytest.mark.asyncio
async def test_check_credits_sufficient(self, credit_service, sample_user) -> None:
"""Test checking credits when user has sufficient credits."""
@@ -394,6 +407,211 @@ class TestCreditService:
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_recharge_user_credits_success(
self, credit_service, sample_user
) -> None:
"""Test successful credit recharge for a user."""
mock_session = credit_service.db_session_factory()
with (
patch("app.services.credit.UserRepository") as mock_repo_class,
patch("app.services.credit.socket_manager") as mock_socket_manager,
):
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
mock_repo.get_by_id.return_value = sample_user
mock_socket_manager.send_to_user = AsyncMock()
# Test recharging 20 credits with max of 100
transaction = await credit_service.recharge_user_credits(1, 20, 100)
# Verify user credits were updated (10 + 20 = 30)
mock_repo.update.assert_called_once_with(sample_user, {"credits": 30})
# Verify transaction was created
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
# Verify socket event was emitted
mock_socket_manager.send_to_user.assert_called_once_with(
"1",
"user_credits_changed",
{
"user_id": "1",
"credits_before": 10,
"credits_after": 30,
"credits_added": 20,
"description": "Daily credit recharge",
"success": True,
},
)
# Check transaction details
assert transaction is not None
added_transaction = mock_session.add.call_args[0][0]
assert isinstance(added_transaction, CreditTransaction)
assert added_transaction.user_id == 1
assert (
added_transaction.action_type == CreditActionType.DAILY_RECHARGE.value
)
assert added_transaction.amount == 20
assert added_transaction.balance_before == 10
assert added_transaction.balance_after == 30
assert added_transaction.success is True
assert json.loads(added_transaction.metadata_json) == {
"plan_credits": 20,
"max_credits": 100,
}
@pytest.mark.asyncio
async def test_recharge_user_credits_at_max(self, credit_service) -> None:
"""Test credit recharge when user is already at max credits."""
mock_session = credit_service.db_session_factory()
max_user = User(
id=1,
name="Max User",
email="max@example.com",
role="user",
credits=100, # Already at max
plan_id=1,
)
with patch("app.services.credit.UserRepository") as mock_repo_class:
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
mock_repo.get_by_id.return_value = max_user
# Test recharging when already at max (100)
transaction = await credit_service.recharge_user_credits(1, 50, 100)
# Verify no credits were added
assert transaction is None
mock_repo.update.assert_not_called()
mock_session.add.assert_not_called()
mock_session.commit.assert_not_called()
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_recharge_user_credits_partial_recharge(self, credit_service) -> None:
"""Test credit recharge when it would exceed max credits."""
mock_session = credit_service.db_session_factory()
high_user = User(
id=1,
name="High User",
email="high@example.com",
role="user",
credits=90, # Close to max
plan_id=1,
)
with (
patch("app.services.credit.UserRepository") as mock_repo_class,
patch("app.services.credit.socket_manager") as mock_socket_manager,
):
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
mock_repo.get_by_id.return_value = high_user
mock_socket_manager.send_to_user = AsyncMock()
# Test recharging 50 credits when max is 100 (should only add 10)
transaction = await credit_service.recharge_user_credits(1, 50, 100)
# Verify only 10 credits were added (90 + 10 = 100)
mock_repo.update.assert_called_once_with(high_user, {"credits": 100})
# Check transaction details
assert transaction is not None
added_transaction = mock_session.add.call_args[0][0]
assert added_transaction.amount == 10 # Only 10 credits added
assert added_transaction.balance_before == 90
assert added_transaction.balance_after == 100
@pytest.mark.asyncio
async def test_recharge_user_credits_user_not_found(self, credit_service) -> None:
"""Test credit recharge when user is not found."""
mock_session = credit_service.db_session_factory()
with patch("app.services.credit.UserRepository") as mock_repo_class:
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
mock_repo.get_by_id.return_value = None
with pytest.raises(ValueError, match="User 999 not found"):
await credit_service.recharge_user_credits(999, 50, 100)
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_recharge_all_users_credits(self, credit_service) -> None:
"""Test recharging credits for all users."""
mock_session = credit_service.db_session_factory()
# Create sample users with plans
user1 = User(id=1, name="User1", email="u1@example.com", credits=10, plan_id=1)
user1.plan = Plan(id=1, code="basic", name="Basic", credits=20, max_credits=50)
user2 = User(id=2, name="User2", email="u2@example.com", credits=45, plan_id=2)
user2.plan = Plan(id=2, code="pro", name="Pro", credits=30, max_credits=100)
user3 = User(id=3, name="User3", email="u3@example.com", credits=100, plan_id=2)
user3.plan = Plan(id=2, code="pro", name="Pro", credits=30, max_credits=100)
with patch("app.services.credit.UserRepository") as mock_repo_class:
mock_repo = AsyncMock()
mock_repo_class.return_value = mock_repo
# Mock get_all_with_plan to return users in batches
mock_repo.get_all_with_plan.side_effect = [
[user1, user2, user3], # First batch
[], # Empty batch to end loop
]
# Mock recharge_user_credits to simulate individual recharges
with patch.object(credit_service, "recharge_user_credits") as mock_recharge:
# Mock return values: transaction for user1 and user2, None for user3
mock_transaction1 = CreditTransaction(
user_id=1,
amount=20,
action_type=CreditActionType.DAILY_RECHARGE.value,
balance_before=10,
balance_after=30,
description="Daily credit recharge",
success=True,
)
mock_transaction2 = CreditTransaction(
user_id=2,
amount=30,
action_type=CreditActionType.DAILY_RECHARGE.value,
balance_before=45,
balance_after=75,
description="Daily credit recharge",
success=True,
)
mock_recharge.side_effect = [
mock_transaction1, # User 1 recharged
mock_transaction2, # User 2 recharged
None, # User 3 at max, no recharge
]
stats = await credit_service.recharge_all_users_credits()
# Verify stats
assert stats == {
"total_users": 3,
"recharged_users": 2,
"skipped_users": 1,
"total_credits_added": 50, # 20 + 30
}
# Verify recharge was called for each user
assert mock_recharge.call_count == 3
mock_recharge.assert_any_call(1, 20, 50)
mock_recharge.assert_any_call(2, 30, 100)
mock_recharge.assert_any_call(3, 30, 100)
mock_session.close.assert_called_once()
class TestInsufficientCreditsError:
"""Test InsufficientCreditsError exception."""