"""Tests for credit decorators.""" from collections.abc import Callable from typing import Never from unittest.mock import AsyncMock 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) -> AsyncMock: """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: AsyncMock) -> Callable[[], AsyncMock]: """Create a credit service factory.""" return lambda: mock_credit_service @pytest.mark.asyncio async def test_decorator_success( self, credit_service_factory: Callable[[], AsyncMock], mock_credit_service: AsyncMock, ) -> None: """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, ) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata=None, ) @pytest.mark.asyncio async def test_decorator_with_metadata( self, credit_service_factory: Callable[[], AsyncMock], mock_credit_service: AsyncMock, ) -> None: """Test decorator with metadata extraction.""" def extract_metadata(user_id: int, sound_name: str) -> dict[str, str]: 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, ) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata={"sound_name": "test.mp3"}, ) @pytest.mark.asyncio async def test_decorator_failed_action(self, credit_service_factory, mock_credit_service) -> None: """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, success=False, metadata=None, ) @pytest.mark.asyncio async def test_decorator_exception_in_action(self, credit_service_factory, mock_credit_service) -> None: """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: msg = "Test error" raise ValueError(msg) 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, success=False, metadata=None, ) @pytest.mark.asyncio async def test_decorator_insufficient_credits(self, credit_service_factory, mock_credit_service) -> None: """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) -> None: """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, ) @pytest.mark.asyncio async def test_decorator_missing_user_id(self, credit_service_factory) -> None: """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) -> AsyncMock: """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: AsyncMock) -> Callable[[], AsyncMock]: """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) -> None: """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) -> AsyncMock: """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) -> None: """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, ) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, success=True, metadata={"test": "data"}, ) @pytest.mark.asyncio async def test_credit_manager_failure(self, mock_credit_service) -> None: """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, success=False, metadata=None, ) @pytest.mark.asyncio async def test_credit_manager_exception(self, mock_credit_service) -> Never: """Test CreditManager when exception occurs.""" with pytest.raises(ValueError, match="Test error"): async with CreditManager( mock_credit_service, 123, CreditActionType.VLC_PLAY_SOUND, ): msg = "Test error" raise ValueError(msg) mock_credit_service.deduct_credits.assert_called_once_with( 123, CreditActionType.VLC_PLAY_SOUND, success=False, metadata=None, ) @pytest.mark.asyncio async def test_credit_manager_validation_failure(self, mock_credit_service) -> None: """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()