"""Tests for credit decorators.""" from unittest.mock import AsyncMock, Mock import pytest from app.models.credit_action import CreditActionType from app.services.credit import CreditService, InsufficientCreditsError from app.utils.credit_decorators import CreditManager, requires_credits, validate_credits_only class TestRequiresCreditsDecorator: """Test requires_credits decorator.""" @pytest.fixture def mock_credit_service(self): """Create a mock credit service.""" service = AsyncMock(spec=CreditService) service.validate_and_reserve_credits = AsyncMock() service.deduct_credits = AsyncMock() return service @pytest.fixture def credit_service_factory(self, mock_credit_service): """Create a credit service factory.""" return lambda: mock_credit_service @pytest.mark.asyncio async def test_decorator_success(self, credit_service_factory, mock_credit_service): """Test decorator with successful action.""" @requires_credits( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id" ) async def test_action(user_id: int, message: str) -> str: return f"Success: {message}" result = await test_action(user_id=123, message="test") assert result == "Success: test" mock_credit_service.validate_and_reserve_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, None ) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, True, None ) @pytest.mark.asyncio async def test_decorator_with_metadata(self, credit_service_factory, mock_credit_service): """Test decorator with metadata extraction.""" def extract_metadata(user_id: int, sound_name: str) -> dict: return {"sound_name": sound_name} @requires_credits( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id", metadata_extractor=extract_metadata ) async def test_action(user_id: int, sound_name: str) -> bool: return True await test_action(user_id=123, sound_name="test.mp3") mock_credit_service.validate_and_reserve_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, {"sound_name": "test.mp3"} ) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, True, {"sound_name": "test.mp3"} ) @pytest.mark.asyncio async def test_decorator_failed_action(self, credit_service_factory, mock_credit_service): """Test decorator with failed action.""" @requires_credits( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id" ) async def test_action(user_id: int) -> bool: return False # Action fails result = await test_action(user_id=123) assert result is False mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, False, None ) @pytest.mark.asyncio async def test_decorator_exception_in_action(self, credit_service_factory, mock_credit_service): """Test decorator when action raises exception.""" @requires_credits( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id" ) async def test_action(user_id: int) -> str: raise ValueError("Test error") with pytest.raises(ValueError, match="Test error"): await test_action(user_id=123) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, False, None ) @pytest.mark.asyncio async def test_decorator_insufficient_credits(self, credit_service_factory, mock_credit_service): """Test decorator with insufficient credits.""" mock_credit_service.validate_and_reserve_credits.side_effect = InsufficientCreditsError(1, 0) @requires_credits( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id" ) async def test_action(user_id: int) -> str: return "Should not execute" with pytest.raises(InsufficientCreditsError): await test_action(user_id=123) # Should not call deduct_credits since validation failed mock_credit_service.deduct_credits.assert_not_called() @pytest.mark.asyncio async def test_decorator_user_id_in_args(self, credit_service_factory, mock_credit_service): """Test decorator extracting user_id from positional args.""" @requires_credits( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id" ) async def test_action(user_id: int, message: str) -> str: return message result = await test_action(123, "test") assert result == "test" mock_credit_service.validate_and_reserve_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, None ) @pytest.mark.asyncio async def test_decorator_missing_user_id(self, credit_service_factory): """Test decorator when user_id cannot be extracted.""" @requires_credits( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id" ) async def test_action(other_param: str) -> str: return other_param with pytest.raises(ValueError, match="Could not extract user_id"): await test_action(other_param="test") class TestValidateCreditsOnlyDecorator: """Test validate_credits_only decorator.""" @pytest.fixture def mock_credit_service(self): """Create a mock credit service.""" service = AsyncMock(spec=CreditService) service.validate_and_reserve_credits = AsyncMock() return service @pytest.fixture def credit_service_factory(self, mock_credit_service): """Create a credit service factory.""" return lambda: mock_credit_service @pytest.mark.asyncio async def test_validate_only_decorator(self, credit_service_factory, mock_credit_service): """Test validate_credits_only decorator.""" @validate_credits_only( CreditActionType.VLC_PLAY_SOUND, credit_service_factory, user_id_param="user_id" ) async def test_action(user_id: int, message: str) -> str: return f"Validated: {message}" result = await test_action(user_id=123, message="test") assert result == "Validated: test" mock_credit_service.validate_and_reserve_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND ) # Should not deduct credits, only validate mock_credit_service.deduct_credits.assert_not_called() class TestCreditManager: """Test CreditManager context manager.""" @pytest.fixture def mock_credit_service(self): """Create a mock credit service.""" service = AsyncMock(spec=CreditService) service.validate_and_reserve_credits = AsyncMock() service.deduct_credits = AsyncMock() return service @pytest.mark.asyncio async def test_credit_manager_success(self, mock_credit_service): """Test CreditManager with successful operation.""" async with CreditManager( mock_credit_service, 123, CreditActionType.VLC_PLAY_SOUND, {"test": "data"} ) as manager: manager.mark_success() mock_credit_service.validate_and_reserve_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, {"test": "data"} ) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, True, {"test": "data"} ) @pytest.mark.asyncio async def test_credit_manager_failure(self, mock_credit_service): """Test CreditManager with failed operation.""" async with CreditManager( mock_credit_service, 123, CreditActionType.VLC_PLAY_SOUND ): # Don't mark as success - should be considered failed pass mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, False, None ) @pytest.mark.asyncio async def test_credit_manager_exception(self, mock_credit_service): """Test CreditManager when exception occurs.""" with pytest.raises(ValueError, match="Test error"): async with CreditManager( mock_credit_service, 123, CreditActionType.VLC_PLAY_SOUND ): raise ValueError("Test error") mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, False, None ) @pytest.mark.asyncio async def test_credit_manager_validation_failure(self, mock_credit_service): """Test CreditManager when validation fails.""" mock_credit_service.validate_and_reserve_credits.side_effect = InsufficientCreditsError(1, 0) with pytest.raises(InsufficientCreditsError): async with CreditManager( mock_credit_service, 123, CreditActionType.VLC_PLAY_SOUND ): pass # Should not call deduct_credits since validation failed mock_credit_service.deduct_credits.assert_not_called()