Files
sdb2-backend/tests/services/test_credit.py
JSC b4f0f54516
All checks were successful
Backend CI / lint (push) Successful in 18m8s
Backend CI / test (push) Successful in 53m35s
Refactor sound and extraction services to include user and timestamp fields
- Updated ExtractionInfo to include user_id, created_at, and updated_at fields.
- Modified ExtractionService to return user and timestamp information in extraction responses.
- Enhanced sound serialization in PlayerState to include extraction URL if available.
- Adjusted PlaylistRepository to load sound extractions when retrieving playlist sounds.
- Added tests for new fields in extraction and sound endpoints, ensuring proper response structure.
- Created new test file endpoints for sound downloads and thumbnail retrievals, including success and error cases.
- Refactored various test cases for consistency and clarity, ensuring proper mocking and assertions.
2025-08-03 20:54:14 +02:00

407 lines
15 KiB
Python

"""Tests for credit service."""
import json
from unittest.mock import AsyncMock, patch
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.credit_action import CreditActionType
from app.models.credit_transaction import CreditTransaction
from app.models.user import User
from app.services.credit import CreditService, InsufficientCreditsError
class TestCreditService:
"""Test credit service functionality."""
@pytest.fixture
def mock_db_session_factory(self):
"""Create a mock database session factory."""
session = AsyncMock(spec=AsyncSession)
return lambda: session
@pytest.fixture
def credit_service(self, mock_db_session_factory):
"""Create a credit service instance for testing."""
return CreditService(mock_db_session_factory)
@pytest.fixture
def sample_user(self):
"""Create a sample user for testing."""
return User(
id=1,
name="Test User",
email="test@example.com",
role="user",
credits=10,
plan_id=1,
)
@pytest.mark.asyncio
async def test_check_credits_sufficient(self, credit_service, sample_user) -> None:
"""Test checking credits when user has sufficient credits."""
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 = sample_user
result = await credit_service.check_credits(
1,
CreditActionType.VLC_PLAY_SOUND,
)
assert result is True
mock_repo.get_by_id.assert_called_once_with(1)
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_check_credits_insufficient(self, credit_service) -> None:
"""Test checking credits when user has insufficient credits."""
mock_session = credit_service.db_session_factory()
poor_user = User(
id=1,
name="Poor User",
email="poor@example.com",
role="user",
credits=0, # No credits
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 = poor_user
result = await credit_service.check_credits(
1,
CreditActionType.VLC_PLAY_SOUND,
)
assert result is False
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_check_credits_user_not_found(self, credit_service) -> None:
"""Test checking credits 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
result = await credit_service.check_credits(
999,
CreditActionType.VLC_PLAY_SOUND,
)
assert result is False
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_validate_and_reserve_credits_success(
self,
credit_service,
sample_user,
) -> None:
"""Test successful credit validation and reservation."""
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 = sample_user
user, action = await credit_service.validate_and_reserve_credits(
1,
CreditActionType.VLC_PLAY_SOUND,
)
assert user == sample_user
assert action.action_type == CreditActionType.VLC_PLAY_SOUND
assert action.cost == 1
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_validate_and_reserve_credits_insufficient(
self,
credit_service,
) -> None:
"""Test credit validation with insufficient credits."""
mock_session = credit_service.db_session_factory()
poor_user = User(
id=1,
name="Poor User",
email="poor@example.com",
role="user",
credits=0,
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 = poor_user
with pytest.raises(InsufficientCreditsError) as exc_info:
await credit_service.validate_and_reserve_credits(
1,
CreditActionType.VLC_PLAY_SOUND,
)
assert exc_info.value.required == 1
assert exc_info.value.available == 0
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_validate_and_reserve_credits_user_not_found(
self,
credit_service,
) -> None:
"""Test credit validation 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.validate_and_reserve_credits(
999,
CreditActionType.VLC_PLAY_SOUND,
)
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_deduct_credits_success(self, credit_service, sample_user) -> None:
"""Test successful credit deduction."""
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()
await credit_service.deduct_credits(
1,
CreditActionType.VLC_PLAY_SOUND,
success=True,
metadata={"test": "data"},
)
# Verify user credits were updated
mock_repo.update.assert_called_once_with(sample_user, {"credits": 9})
# 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": 9,
"credits_deducted": 1,
"action_type": "vlc_play_sound",
"success": True,
},
)
# Check transaction details
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 == "vlc_play_sound"
assert added_transaction.amount == -1
assert added_transaction.balance_before == 10
assert added_transaction.balance_after == 9
assert added_transaction.success is True
assert json.loads(added_transaction.metadata_json) == {"test": "data"}
@pytest.mark.asyncio
async def test_deduct_credits_failed_action_requires_success(
self,
credit_service,
sample_user,
) -> None:
"""Test credit deduction when action failed but requires success."""
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()
await credit_service.deduct_credits(
1,
CreditActionType.VLC_PLAY_SOUND,
success=False, # Action failed
)
# Verify user credits were NOT updated (action requires success)
mock_repo.update.assert_not_called()
# Verify transaction was still created for auditing
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
# Verify no socket event was emitted since no credits were actually deducted
mock_socket_manager.send_to_user.assert_not_called()
# Check transaction details
added_transaction = mock_session.add.call_args[0][0]
assert added_transaction.amount == 0 # No deduction for failed action
assert added_transaction.balance_before == 10
assert added_transaction.balance_after == 10 # No change
assert added_transaction.success is False
@pytest.mark.asyncio
async def test_deduct_credits_insufficient(self, credit_service) -> None:
"""Test credit deduction with insufficient credits."""
mock_session = credit_service.db_session_factory()
poor_user = User(
id=1,
name="Poor User",
email="poor@example.com",
role="user",
credits=0,
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 = poor_user
mock_socket_manager.send_to_user = AsyncMock()
with pytest.raises(InsufficientCreditsError):
await credit_service.deduct_credits(
1,
CreditActionType.VLC_PLAY_SOUND,
success=True,
)
# Verify no socket event was emitted since credits could not be deducted
mock_socket_manager.send_to_user.assert_not_called()
mock_session.rollback.assert_called_once()
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_add_credits(self, credit_service, sample_user) -> None:
"""Test adding credits to user account."""
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()
await credit_service.add_credits(
1,
5,
"Bonus credits",
{"reason": "signup"},
)
# Verify user credits were updated
mock_repo.update.assert_called_once_with(sample_user, {"credits": 15})
# 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": 15,
"credits_added": 5,
"description": "Bonus credits",
"success": True,
},
)
# Check transaction details
added_transaction = mock_session.add.call_args[0][0]
assert added_transaction.amount == 5
assert added_transaction.balance_before == 10
assert added_transaction.balance_after == 15
assert added_transaction.description == "Bonus credits"
@pytest.mark.asyncio
async def test_add_credits_invalid_amount(self, credit_service) -> None:
"""Test adding invalid amount of credits."""
with pytest.raises(ValueError, match="Amount must be positive"):
await credit_service.add_credits(1, 0, "Invalid")
with pytest.raises(ValueError, match="Amount must be positive"):
await credit_service.add_credits(1, -5, "Invalid")
@pytest.mark.asyncio
async def test_get_user_balance(self, credit_service, sample_user) -> None:
"""Test getting user credit balance."""
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 = sample_user
balance = await credit_service.get_user_balance(1)
assert balance == 10
mock_session.close.assert_called_once()
@pytest.mark.asyncio
async def test_get_user_balance_user_not_found(self, credit_service) -> None:
"""Test getting balance for non-existent user."""
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.get_user_balance(999)
mock_session.close.assert_called_once()
class TestInsufficientCreditsError:
"""Test InsufficientCreditsError exception."""
def test_insufficient_credits_error_creation(self) -> None:
"""Test creating InsufficientCreditsError."""
error = InsufficientCreditsError(5, 2)
assert error.required == 5
assert error.available == 2
assert str(error) == "Insufficient credits: 5 required, 2 available"