Files
sdb2-backend/tests/utils/test_credit_decorators.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

350 lines
11 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()