"""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): """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): """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): """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): """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): """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): """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): """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() transaction = 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): """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() transaction = 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): """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): """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() transaction = 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): """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): """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): """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): """Test creating InsufficientCreditsError.""" error = InsufficientCreditsError(5, 2) assert error.required == 5 assert error.available == 2 assert str(error) == "Insufficient credits: 5 required, 2 available"