290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""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()
|