Add tests for extraction API endpoints and enhance existing tests
Some checks failed
Backend CI / lint (push) Successful in 9m25s
Backend CI / test (push) Failing after 4m48s

- Implement tests for admin extraction API endpoints including status retrieval, deletion of extractions, and permission checks.
- Add tests for user extraction deletion, ensuring proper handling of permissions and non-existent extractions.
- Enhance sound endpoint tests to include duplicate handling in responses.
- Refactor favorite service tests to utilize mock dependencies for better maintainability and clarity.
- Update sound scanner tests to improve file handling and ensure proper deletion of associated files.
This commit is contained in:
JSC
2025-08-25 21:40:31 +02:00
parent d3ce17f10d
commit 7dee6e320e
15 changed files with 1560 additions and 721 deletions

View File

@@ -0,0 +1,154 @@
"""Tests for admin extraction API endpoints."""
import pytest
from httpx import AsyncClient
from app.models.extraction import Extraction
from app.models.user import User
class TestAdminExtractionEndpoints:
"""Test admin extraction endpoints."""
@pytest.mark.asyncio
async def test_get_extraction_processor_status(self, authenticated_admin_client):
"""Test getting extraction processor status."""
response = await authenticated_admin_client.get(
"/api/v1/admin/extractions/status",
)
assert response.status_code == 200
data = response.json()
# Check expected status fields (match actual processor status format)
assert "currently_processing" in data
assert "max_concurrent" in data
assert "available_slots" in data
assert "processing_ids" in data
assert isinstance(data["currently_processing"], int)
assert isinstance(data["max_concurrent"], int)
assert isinstance(data["available_slots"], int)
assert isinstance(data["processing_ids"], list)
@pytest.mark.asyncio
async def test_admin_delete_extraction_success(
self,
authenticated_admin_client,
test_session,
test_plan,
):
"""Test admin successfully deleting any extraction."""
# Create a test user
user = User(
name="Test User",
email="test@example.com",
is_active=True,
plan_id=test_plan.id,
)
test_session.add(user)
await test_session.commit()
await test_session.refresh(user)
# Create test extraction
extraction = Extraction(
url="https://example.com/video",
user_id=user.id,
status="completed",
)
test_session.add(extraction)
await test_session.commit()
await test_session.refresh(extraction)
# Admin delete the extraction
response = await authenticated_admin_client.delete(
f"/api/v1/admin/extractions/{extraction.id}",
)
assert response.status_code == 200
data = response.json()
assert data["message"] == f"Extraction {extraction.id} deleted successfully"
# Verify extraction was deleted from database
deleted_extraction = await test_session.get(Extraction, extraction.id)
assert deleted_extraction is None
@pytest.mark.asyncio
async def test_admin_delete_extraction_not_found(self, authenticated_admin_client):
"""Test admin deleting non-existent extraction."""
response = await authenticated_admin_client.delete(
"/api/v1/admin/extractions/999",
)
assert response.status_code == 404
data = response.json()
assert "not found" in data["detail"].lower()
@pytest.mark.asyncio
async def test_admin_delete_extraction_any_user(
self,
authenticated_admin_client,
test_session,
test_plan,
):
"""Test admin deleting extraction owned by any user."""
# Create another user and their extraction
other_user = User(
name="Other User",
email="other@example.com",
is_active=True,
plan_id=test_plan.id,
)
test_session.add(other_user)
await test_session.commit()
await test_session.refresh(other_user)
extraction = Extraction(
url="https://example.com/video",
user_id=other_user.id,
status="completed",
)
test_session.add(extraction)
await test_session.commit()
await test_session.refresh(extraction)
# Admin can delete any user's extraction
response = await authenticated_admin_client.delete(
f"/api/v1/admin/extractions/{extraction.id}",
)
assert response.status_code == 200
data = response.json()
assert data["message"] == f"Extraction {extraction.id} deleted successfully"
@pytest.mark.asyncio
async def test_delete_extraction_non_admin(self, authenticated_client, test_user, test_session):
"""Test non-admin user cannot access admin deletion endpoint."""
# Create test extraction
extraction = Extraction(
url="https://example.com/video",
user_id=test_user.id,
status="completed",
)
test_session.add(extraction)
await test_session.commit()
await test_session.refresh(extraction)
# Non-admin user cannot access admin endpoint
response = await authenticated_client.delete(
f"/api/v1/admin/extractions/{extraction.id}",
)
assert response.status_code == 403
data = response.json()
assert "permissions" in data["detail"].lower()
@pytest.mark.asyncio
async def test_admin_endpoints_unauthenticated(self, client: AsyncClient):
"""Test admin endpoints require authentication."""
# Status endpoint
response = await client.get("/api/v1/admin/extractions/status")
assert response.status_code == 401
# Delete endpoint
response = await client.delete("/api/v1/admin/extractions/1")
assert response.status_code == 401

View File

@@ -31,6 +31,7 @@ class TestAdminSoundEndpoints:
"deleted": 1,
"skipped": 0,
"errors": 0,
"duplicates": 0,
"files": [
{
"filename": "test1.mp3",
@@ -176,6 +177,7 @@ class TestAdminSoundEndpoints:
"deleted": 0,
"skipped": 0,
"errors": 0,
"duplicates": 0,
"files": [
{
"filename": "custom1.wav",

View File

@@ -229,3 +229,73 @@ class TestExtractionEndpoints:
break
assert processing_found, "Processing extraction not found in results"
@pytest.mark.asyncio
async def test_delete_extraction_success(self, authenticated_client, test_user, test_session):
"""Test successful deletion of user's own extraction."""
# Create test extraction
extraction = Extraction(
url="https://example.com/video",
user_id=test_user.id,
status="completed",
)
test_session.add(extraction)
await test_session.commit()
await test_session.refresh(extraction)
# Delete the extraction
response = await authenticated_client.delete(f"/api/v1/extractions/{extraction.id}")
assert response.status_code == 200
data = response.json()
assert data["message"] == f"Extraction {extraction.id} deleted successfully"
# Verify extraction was deleted from database
deleted_extraction = await test_session.get(Extraction, extraction.id)
assert deleted_extraction is None
@pytest.mark.asyncio
async def test_delete_extraction_not_found(self, authenticated_client):
"""Test deleting non-existent extraction."""
response = await authenticated_client.delete("/api/v1/extractions/999")
assert response.status_code == 404
data = response.json()
assert "not found" in data["detail"].lower()
@pytest.mark.asyncio
async def test_delete_extraction_permission_denied(self, authenticated_client, test_session, test_plan):
"""Test deleting another user's extraction."""
# Create extraction owned by different user
other_user = User(
name="Other User",
email="other@example.com",
is_active=True,
plan_id=test_plan.id,
)
test_session.add(other_user)
await test_session.commit()
await test_session.refresh(other_user)
extraction = Extraction(
url="https://example.com/video",
user_id=other_user.id,
status="completed",
)
test_session.add(extraction)
await test_session.commit()
await test_session.refresh(extraction)
# Try to delete other user's extraction
response = await authenticated_client.delete(f"/api/v1/extractions/{extraction.id}")
assert response.status_code == 403
data = response.json()
assert "permission" in data["detail"].lower()
@pytest.mark.asyncio
async def test_delete_extraction_unauthenticated(self, client):
"""Test deleting extraction without authentication."""
response = await client.delete("/api/v1/extractions/1")
assert response.status_code == 401

View File

@@ -1,5 +1,7 @@
"""Tests for favorite API endpoints."""
from contextlib import suppress
import pytest
import pytest_asyncio
from httpx import AsyncClient
@@ -129,10 +131,8 @@ class TestFavoriteEndpoints:
) -> None:
"""Test successfully adding a sound to favorites."""
# Clean up any existing favorite first
try:
with suppress(Exception):
await authenticated_client.delete("/api/v1/favorites/sounds/1")
except:
pass # It's ok if it doesn't exist
response = await authenticated_client.post("/api/v1/favorites/sounds/1")
@@ -176,10 +176,8 @@ class TestFavoriteEndpoints:
) -> None:
"""Test successfully adding a playlist to favorites."""
# Clean up any existing favorite first
try:
with suppress(Exception):
await authenticated_client.delete("/api/v1/favorites/playlists/1")
except:
pass # It's ok if it doesn't exist
response = await authenticated_client.post("/api/v1/favorites/playlists/1")
@@ -473,10 +471,8 @@ class TestFavoriteEndpoints:
) -> None:
"""Test checking if a sound is favorited (false case)."""
# Make sure sound 1 is not favorited
try:
with suppress(Exception):
await authenticated_client.delete("/api/v1/favorites/sounds/1")
except:
pass # It's ok if it doesn't exist
response = await authenticated_client.get("/api/v1/favorites/sounds/1/check")
@@ -509,10 +505,8 @@ class TestFavoriteEndpoints:
) -> None:
"""Test checking if a playlist is favorited (false case)."""
# Make sure playlist 1 is not favorited
try:
with suppress(Exception):
await authenticated_client.delete("/api/v1/favorites/playlists/1")
except:
pass # It's ok if it doesn't exist
response = await authenticated_client.get("/api/v1/favorites/playlists/1/check")

View File

@@ -144,7 +144,7 @@ class TestExtractionRepository:
),
Extraction(
id=2,
service="youtube",
service="youtube",
service_id="test456",
url="https://www.youtube.com/watch?v=test2",
user_id=1,

View File

@@ -541,3 +541,143 @@ class TestExtractionService:
assert result[0]["id"] == 1
assert result[0]["status"] == "pending"
assert result[0]["user_name"] == "Test User"
@pytest.mark.asyncio
async def test_delete_extraction_with_sound(self, extraction_service, test_user):
"""Test deleting extraction with associated sound and files."""
import tempfile
from pathlib import Path
# Create temporary directories for testing
with tempfile.TemporaryDirectory() as temp_dir:
temp_dir_path = Path(temp_dir)
# Set up temporary directories
original_dir = temp_dir_path / "originals" / "extracted"
normalized_dir = temp_dir_path / "normalized" / "extracted"
thumbnail_dir = temp_dir_path / "thumbnails"
original_dir.mkdir(parents=True)
normalized_dir.mkdir(parents=True)
thumbnail_dir.mkdir(parents=True)
# Create test files
audio_file = original_dir / "test_audio.mp3"
normalized_file = normalized_dir / "test_audio.mp3"
thumbnail_file = thumbnail_dir / "test_thumb.jpg"
audio_file.write_text("audio content")
normalized_file.write_text("normalized content")
thumbnail_file.write_text("thumbnail content")
# Create extraction and sound records
extraction = Extraction(
id=1,
url="https://example.com/video",
user_id=test_user.id,
status="completed",
sound_id=1,
)
sound = Sound(
id=1,
type="EXT",
name="Test Audio",
filename="test_audio.mp3",
duration=60000,
size=2048,
hash="test_hash",
is_normalized=True,
normalized_filename="test_audio.mp3",
thumbnail="test_thumb.jpg",
is_deletable=True,
is_music=True,
)
# Mock repository methods
extraction_service.extraction_repo.get_by_id = AsyncMock(return_value=extraction)
extraction_service.sound_repo.get_by_id = AsyncMock(return_value=sound)
extraction_service.extraction_repo.delete = AsyncMock()
extraction_service.sound_repo.delete = AsyncMock()
extraction_service.session.commit = AsyncMock()
extraction_service.session.rollback = AsyncMock()
# Monkey patch the paths in the service method
import app.services.extraction
original_path_class = app.services.extraction.Path
def mock_path(*args: str):
path_str = str(args[0])
if path_str == "sounds/originals/extracted":
return original_dir
if path_str == "sounds/normalized/extracted":
return normalized_dir
if path_str.endswith("thumbnails"):
return thumbnail_dir
return original_path_class(*args)
# Mock the Path constructor and settings
with patch("app.services.extraction.Path", side_effect=mock_path), \
patch("app.services.extraction.settings") as mock_settings:
mock_settings.EXTRACTION_THUMBNAILS_DIR = str(thumbnail_dir)
# Test deletion
result = await extraction_service.delete_extraction(1, test_user.id)
assert result is True
# Verify repository calls
extraction_service.extraction_repo.get_by_id.assert_called_once_with(1)
extraction_service.sound_repo.get_by_id.assert_called_once_with(1)
extraction_service.extraction_repo.delete.assert_called_once_with(extraction)
extraction_service.sound_repo.delete.assert_called_once_with(sound)
extraction_service.session.commit.assert_called_once()
# Verify files were deleted
assert not audio_file.exists()
assert not normalized_file.exists()
assert not thumbnail_file.exists()
@pytest.mark.asyncio
async def test_delete_extraction_not_found(self, extraction_service, test_user):
"""Test deleting non-existent extraction."""
extraction_service.extraction_repo.get_by_id = AsyncMock(return_value=None)
result = await extraction_service.delete_extraction(999, test_user.id)
assert result is False
@pytest.mark.asyncio
async def test_delete_extraction_permission_denied(self, extraction_service, test_user):
"""Test deleting extraction owned by another user."""
extraction = Extraction(
id=1,
url="https://example.com/video",
user_id=999, # Different user ID
status="completed",
)
extraction_service.extraction_repo.get_by_id = AsyncMock(return_value=extraction)
with pytest.raises(ValueError, match="You don't have permission"):
await extraction_service.delete_extraction(1, test_user.id)
@pytest.mark.asyncio
async def test_delete_extraction_admin(self, extraction_service, test_user):
"""Test admin deleting any extraction."""
extraction = Extraction(
id=1,
url="https://example.com/video",
user_id=999, # Different user ID
status="completed",
)
extraction_service.extraction_repo.get_by_id = AsyncMock(return_value=extraction)
extraction_service.extraction_repo.delete = AsyncMock()
extraction_service.session.commit = AsyncMock()
# Admin deletion (user_id=None)
result = await extraction_service.delete_extraction(1, None)
assert result is True
extraction_service.extraction_repo.delete.assert_called_once_with(extraction)

View File

@@ -1,6 +1,7 @@
"""Tests for favorite service."""
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -14,6 +15,31 @@ from app.models.user import User
from app.services.favorite import FavoriteService
@dataclass
class MockRepositories:
"""Container for all mock repositories."""
user_repo: AsyncMock
favorite_repo: AsyncMock
sound_repo: AsyncMock
socket_manager: AsyncMock
@dataclass
class MockServiceDependencies:
"""Container for all mock service dependencies."""
sound_repo_class: AsyncMock
user_repo_class: AsyncMock
favorite_repo_class: AsyncMock
socket_manager: AsyncMock
sound_repo: AsyncMock
user_repo: AsyncMock
favorite_repo: AsyncMock
playlist_repo_class: AsyncMock | None = None
playlist_repo: AsyncMock | None = None
class TestFavoriteService:
"""Test favorite service operations."""
@@ -71,34 +97,75 @@ class TestFavoriteService:
"playlist_repo": AsyncMock(),
}
@patch("app.services.favorite.socket_manager")
@patch("app.services.favorite.FavoriteRepository")
@patch("app.services.favorite.UserRepository")
@patch("app.services.favorite.SoundRepository")
@pytest_asyncio.fixture
async def mock_sound_favorite_dependencies(self) -> MockServiceDependencies:
"""Create mock dependencies for sound favorite operations."""
with (
patch("app.services.favorite.SoundRepository") as mock_sound_repo_class,
patch("app.services.favorite.UserRepository") as mock_user_repo_class,
patch("app.services.favorite.FavoriteRepository") as mock_favorite_repo_class,
patch("app.services.favorite.socket_manager") as mock_socket_manager,
):
mock_sound_repo = AsyncMock()
mock_user_repo = AsyncMock()
mock_favorite_repo = AsyncMock()
mock_sound_repo_class.return_value = mock_sound_repo
mock_user_repo_class.return_value = mock_user_repo
mock_favorite_repo_class.return_value = mock_favorite_repo
yield MockServiceDependencies(
sound_repo_class=mock_sound_repo_class,
user_repo_class=mock_user_repo_class,
favorite_repo_class=mock_favorite_repo_class,
socket_manager=mock_socket_manager,
sound_repo=mock_sound_repo,
user_repo=mock_user_repo,
favorite_repo=mock_favorite_repo,
)
@pytest_asyncio.fixture
async def mock_playlist_favorite_dependencies(self) -> MockServiceDependencies:
"""Create mock dependencies for playlist favorite operations."""
with (
patch("app.services.favorite.UserRepository") as mock_user_repo_class,
patch("app.services.favorite.PlaylistRepository") as mock_playlist_repo_class,
patch("app.services.favorite.FavoriteRepository") as mock_favorite_repo_class,
):
mock_user_repo = AsyncMock()
mock_playlist_repo = AsyncMock()
mock_favorite_repo = AsyncMock()
mock_user_repo_class.return_value = mock_user_repo
mock_playlist_repo_class.return_value = mock_playlist_repo
mock_favorite_repo_class.return_value = mock_favorite_repo
yield MockServiceDependencies(
sound_repo_class=AsyncMock(), # Not used in playlist tests
user_repo_class=mock_user_repo_class,
favorite_repo_class=mock_favorite_repo_class,
socket_manager=AsyncMock(), # Not used in playlist tests
sound_repo=AsyncMock(), # Not used in playlist tests
user_repo=mock_user_repo,
favorite_repo=mock_favorite_repo,
playlist_repo_class=mock_playlist_repo_class,
playlist_repo=mock_playlist_repo,
)
@pytest.mark.asyncio
async def test_add_sound_favorite_success(
self,
mock_sound_repo_class: AsyncMock,
mock_user_repo_class: AsyncMock,
mock_favorite_repo_class: AsyncMock,
mock_socket_manager: AsyncMock,
mock_sound_favorite_dependencies: MockServiceDependencies,
favorite_service: FavoriteService,
test_user: User,
test_sound: Sound,
) -> None:
"""Test successfully adding a sound favorite."""
# Setup mocks
mock_favorite_repo = AsyncMock()
mock_user_repo = AsyncMock()
mock_sound_repo = AsyncMock()
mock_favorite_repo_class.return_value = mock_favorite_repo
mock_user_repo_class.return_value = mock_user_repo
mock_sound_repo_class.return_value = mock_sound_repo
mock_user_repo.get_by_id.return_value = test_user
mock_sound_repo.get_by_id.return_value = test_sound
mock_favorite_repo.get_by_user_and_sound.return_value = None
mocks = mock_sound_favorite_dependencies
mocks.user_repo.get_by_id.return_value = test_user
mocks.sound_repo.get_by_id.return_value = test_sound
mocks.favorite_repo.get_by_user_and_sound.return_value = None
expected_favorite = Favorite(
id=1,
@@ -106,23 +173,23 @@ class TestFavoriteService:
sound_id=test_sound.id,
playlist_id=None,
)
mock_favorite_repo.create.return_value = expected_favorite
mock_favorite_repo.count_sound_favorites.return_value = 1
mocks.favorite_repo.create.return_value = expected_favorite
mocks.favorite_repo.count_sound_favorites.return_value = 1
# Execute
result = await favorite_service.add_sound_favorite(test_user.id, test_sound.id)
# Verify
assert result == expected_favorite
mock_user_repo.get_by_id.assert_called_once_with(test_user.id)
mock_sound_repo.get_by_id.assert_called_once_with(test_sound.id)
mock_favorite_repo.get_by_user_and_sound.assert_called_once_with(test_user.id, test_sound.id)
mock_favorite_repo.create.assert_called_once_with({
mocks.user_repo.get_by_id.assert_called_once_with(test_user.id)
mocks.sound_repo.get_by_id.assert_called_once_with(test_sound.id)
mocks.favorite_repo.get_by_user_and_sound.assert_called_once_with(test_user.id, test_sound.id)
mocks.favorite_repo.create.assert_called_once_with({
"user_id": test_user.id,
"sound_id": test_sound.id,
"playlist_id": None,
})
mock_socket_manager.broadcast_to_all.assert_called_once()
mocks.socket_manager.broadcast_to_all.assert_called_once()
@patch("app.services.favorite.UserRepository")
@pytest.mark.asyncio
@@ -161,62 +228,38 @@ class TestFavoriteService:
with pytest.raises(ValueError, match="Sound with ID 1 not found"):
await favorite_service.add_sound_favorite(test_user.id, 1)
@patch("app.services.favorite.FavoriteRepository")
@patch("app.services.favorite.SoundRepository")
@patch("app.services.favorite.UserRepository")
@pytest.mark.asyncio
async def test_add_sound_favorite_already_exists(
self,
mock_user_repo_class: AsyncMock,
mock_sound_repo_class: AsyncMock,
mock_favorite_repo_class: AsyncMock,
mock_sound_favorite_dependencies: MockServiceDependencies,
favorite_service: FavoriteService,
test_user: User,
test_sound: Sound,
) -> None:
"""Test adding sound favorite that already exists."""
mock_user_repo = AsyncMock()
mock_sound_repo = AsyncMock()
mock_favorite_repo = AsyncMock()
mock_user_repo_class.return_value = mock_user_repo
mock_sound_repo_class.return_value = mock_sound_repo
mock_favorite_repo_class.return_value = mock_favorite_repo
mock_user_repo.get_by_id.return_value = test_user
mock_sound_repo.get_by_id.return_value = test_sound
mocks = mock_sound_favorite_dependencies
mocks.user_repo.get_by_id.return_value = test_user
mocks.sound_repo.get_by_id.return_value = test_sound
existing_favorite = Favorite(user_id=test_user.id, sound_id=test_sound.id)
mock_favorite_repo.get_by_user_and_sound.return_value = existing_favorite
mocks.favorite_repo.get_by_user_and_sound.return_value = existing_favorite
with pytest.raises(ValueError, match="already favorited"):
await favorite_service.add_sound_favorite(test_user.id, test_sound.id)
@patch("app.services.favorite.FavoriteRepository")
@patch("app.services.favorite.PlaylistRepository")
@patch("app.services.favorite.UserRepository")
@pytest.mark.asyncio
async def test_add_playlist_favorite_success(
self,
mock_user_repo_class: AsyncMock,
mock_playlist_repo_class: AsyncMock,
mock_favorite_repo_class: AsyncMock,
mock_playlist_favorite_dependencies: MockServiceDependencies,
favorite_service: FavoriteService,
test_user: User,
test_playlist: Playlist,
) -> None:
"""Test successfully adding a playlist favorite."""
# Setup mocks
mock_favorite_repo = AsyncMock()
mock_user_repo = AsyncMock()
mock_playlist_repo = AsyncMock()
mock_favorite_repo_class.return_value = mock_favorite_repo
mock_user_repo_class.return_value = mock_user_repo
mock_playlist_repo_class.return_value = mock_playlist_repo
mock_user_repo.get_by_id.return_value = test_user
mock_playlist_repo.get_by_id.return_value = test_playlist
mock_favorite_repo.get_by_user_and_playlist.return_value = None
mocks = mock_playlist_favorite_dependencies
mocks.user_repo.get_by_id.return_value = test_user
mocks.playlist_repo.get_by_id.return_value = test_playlist
mocks.favorite_repo.get_by_user_and_playlist.return_value = None
expected_favorite = Favorite(
id=1,
@@ -224,59 +267,45 @@ class TestFavoriteService:
sound_id=None,
playlist_id=test_playlist.id,
)
mock_favorite_repo.create.return_value = expected_favorite
mocks.favorite_repo.create.return_value = expected_favorite
# Execute
result = await favorite_service.add_playlist_favorite(test_user.id, test_playlist.id)
# Verify
assert result == expected_favorite
mock_user_repo.get_by_id.assert_called_once_with(test_user.id)
mock_playlist_repo.get_by_id.assert_called_once_with(test_playlist.id)
mock_favorite_repo.get_by_user_and_playlist.assert_called_once_with(test_user.id, test_playlist.id)
mock_favorite_repo.create.assert_called_once_with({
mocks.user_repo.get_by_id.assert_called_once_with(test_user.id)
mocks.playlist_repo.get_by_id.assert_called_once_with(test_playlist.id)
mocks.favorite_repo.get_by_user_and_playlist.assert_called_once_with(test_user.id, test_playlist.id)
mocks.favorite_repo.create.assert_called_once_with({
"user_id": test_user.id,
"sound_id": None,
"playlist_id": test_playlist.id,
})
@patch("app.services.favorite.socket_manager")
@patch("app.services.favorite.FavoriteRepository")
@patch("app.services.favorite.SoundRepository")
@patch("app.services.favorite.UserRepository")
@pytest.mark.asyncio
async def test_remove_sound_favorite_success(
self,
mock_user_repo_class: AsyncMock,
mock_sound_repo_class: AsyncMock,
mock_favorite_repo_class: AsyncMock,
mock_socket_manager: AsyncMock,
mock_sound_favorite_dependencies: MockServiceDependencies,
favorite_service: FavoriteService,
test_user: User,
test_sound: Sound,
) -> None:
"""Test successfully removing a sound favorite."""
mock_favorite_repo = AsyncMock()
mock_user_repo = AsyncMock()
mock_sound_repo = AsyncMock()
mock_favorite_repo_class.return_value = mock_favorite_repo
mock_user_repo_class.return_value = mock_user_repo
mock_sound_repo_class.return_value = mock_sound_repo
mocks = mock_sound_favorite_dependencies
existing_favorite = Favorite(user_id=test_user.id, sound_id=test_sound.id)
mock_favorite_repo.get_by_user_and_sound.return_value = existing_favorite
mock_user_repo.get_by_id.return_value = test_user
mock_sound_repo.get_by_id.return_value = test_sound
mock_favorite_repo.count_sound_favorites.return_value = 0
mocks.favorite_repo.get_by_user_and_sound.return_value = existing_favorite
mocks.user_repo.get_by_id.return_value = test_user
mocks.sound_repo.get_by_id.return_value = test_sound
mocks.favorite_repo.count_sound_favorites.return_value = 0
# Execute
await favorite_service.remove_sound_favorite(test_user.id, test_sound.id)
# Verify
mock_favorite_repo.get_by_user_and_sound.assert_called_once_with(test_user.id, test_sound.id)
mock_favorite_repo.delete.assert_called_once_with(existing_favorite)
mock_socket_manager.broadcast_to_all.assert_called_once()
mocks.favorite_repo.get_by_user_and_sound.assert_called_once_with(test_user.id, test_sound.id)
mocks.favorite_repo.delete.assert_called_once_with(existing_favorite)
mocks.socket_manager.broadcast_to_all.assert_called_once()
@patch("app.services.favorite.FavoriteRepository")
@pytest.mark.asyncio
@@ -503,46 +532,31 @@ class TestFavoriteService:
assert result == 3
mock_favorite_repo.count_playlist_favorites.assert_called_once_with(1)
@patch("app.services.favorite.socket_manager")
@patch("app.services.favorite.FavoriteRepository")
@patch("app.services.favorite.SoundRepository")
@patch("app.services.favorite.UserRepository")
@pytest.mark.asyncio
async def test_socket_broadcast_error_handling(
self,
mock_user_repo_class: AsyncMock,
mock_sound_repo_class: AsyncMock,
mock_favorite_repo_class: AsyncMock,
mock_socket_manager: AsyncMock,
mock_sound_favorite_dependencies: MockServiceDependencies,
favorite_service: FavoriteService,
test_user: User,
test_sound: Sound,
) -> None:
"""Test that socket broadcast errors don't affect the operation."""
# Setup mocks
mock_favorite_repo = AsyncMock()
mock_user_repo = AsyncMock()
mock_sound_repo = AsyncMock()
mock_favorite_repo_class.return_value = mock_favorite_repo
mock_user_repo_class.return_value = mock_user_repo
mock_sound_repo_class.return_value = mock_sound_repo
mock_user_repo.get_by_id.return_value = test_user
mock_sound_repo.get_by_id.return_value = test_sound
mock_favorite_repo.get_by_user_and_sound.return_value = None
mocks = mock_sound_favorite_dependencies
mocks.user_repo.get_by_id.return_value = test_user
mocks.sound_repo.get_by_id.return_value = test_sound
mocks.favorite_repo.get_by_user_and_sound.return_value = None
expected_favorite = Favorite(id=1, user_id=test_user.id, sound_id=test_sound.id)
mock_favorite_repo.create.return_value = expected_favorite
mock_favorite_repo.count_sound_favorites.return_value = 1
mocks.favorite_repo.create.return_value = expected_favorite
mocks.favorite_repo.count_sound_favorites.return_value = 1
# Make socket broadcast raise an exception
mock_socket_manager.broadcast_to_all.side_effect = Exception("Socket error")
mocks.socket_manager.broadcast_to_all.side_effect = Exception("Socket error")
# Execute - should not raise exception despite socket error
result = await favorite_service.add_sound_favorite(test_user.id, test_sound.id)
# Verify operation still succeeded
assert result == expected_favorite
mock_favorite_repo.create.assert_called_once()
mocks.favorite_repo.create.assert_called_once()

View File

@@ -8,7 +8,7 @@ import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.sound import Sound
from app.services.sound_scanner import SoundScannerService
from app.services.sound_scanner import SoundScannerService, SyncContext
class TestSoundScannerService:
@@ -154,15 +154,15 @@ class TestSoundScannerService:
}
# Set the existing sound filename to match temp file for "unchanged" test
existing_sound.filename = temp_path.name
await scanner_service._sync_audio_file(
temp_path,
"SDB",
existing_sound, # existing_sound_by_hash (same hash)
None, # existing_sound_by_filename (no conflict)
"same_hash",
results,
sync_context = SyncContext(
file_path=temp_path,
sound_type="SDB",
existing_sound_by_hash=existing_sound,
existing_sound_by_filename=None,
file_hash="same_hash",
)
await scanner_service._sync_audio_file(sync_context, results)
assert results["skipped"] == 1
assert results["added"] == 0
@@ -186,7 +186,7 @@ class TestSoundScannerService:
size=1024,
hash="same_hash",
)
scanner_service.sound_repo.update = AsyncMock(return_value=existing_sound)
# Mock file operations to return same hash
@@ -209,15 +209,15 @@ class TestSoundScannerService:
"errors": 0,
"files": [],
}
await scanner_service._sync_audio_file(
temp_path,
"SDB",
existing_sound, # existing_sound_by_hash (same hash)
None, # existing_sound_by_filename (different filename)
"same_hash",
results,
sync_context = SyncContext(
file_path=temp_path,
sound_type="SDB",
existing_sound_by_hash=existing_sound,
existing_sound_by_filename=None,
file_hash="same_hash",
)
await scanner_service._sync_audio_file(sync_context, results)
# Should be marked as updated (renamed)
assert results["updated"] == 1
@@ -227,12 +227,12 @@ class TestSoundScannerService:
assert results["files"][0]["status"] == "updated"
assert results["files"][0]["reason"] == "file was renamed"
assert results["files"][0]["changes"] == ["filename", "name"]
# Verify update was called with new filename
scanner_service.sound_repo.update.assert_called_once()
call_args = scanner_service.sound_repo.update.call_args[0][1] # update_data
assert call_args["filename"] == temp_path.name
finally:
temp_path.unlink()
@@ -249,22 +249,21 @@ class TestSoundScannerService:
size=1024,
hash="same_hash",
)
# Mock the repository to return the existing sound
scanner_service.sound_repo.get_by_type = AsyncMock(return_value=[existing_sound])
scanner_service.sound_repo.update = AsyncMock()
scanner_service.sound_repo.delete = AsyncMock()
# Create temporary directory with renamed file
import tempfile
import os
with tempfile.TemporaryDirectory() as temp_dir:
# Create the "renamed" file (same hash, different name)
new_file_path = os.path.join(temp_dir, "new_name.mp3")
with open(new_file_path, "wb") as f:
new_file_path = Path(temp_dir) / "new_name.mp3"
with new_file_path.open("wb") as f:
f.write(b"test audio content") # This will produce consistent hash
# Mock file operations to return same hash
with (
patch("app.services.sound_scanner.get_file_hash", return_value="same_hash"),
@@ -272,18 +271,18 @@ class TestSoundScannerService:
patch("app.services.sound_scanner.get_file_size", return_value=1024),
):
results = await scanner_service.scan_directory(temp_dir, "SDB")
# Should have detected one renamed file
assert results["updated"] == 1
assert results["deleted"] == 0 # This is the key assertion - no deletion!
assert results["added"] == 0
assert len(results["files"]) == 1
# Verify it was marked as renamed
file_result = results["files"][0]
assert file_result["status"] == "updated"
assert file_result["reason"] == "file was renamed"
# Verify update was called but delete was NOT called
scanner_service.sound_repo.update.assert_called_once()
scanner_service.sound_repo.delete.assert_not_called()
@@ -301,25 +300,24 @@ class TestSoundScannerService:
size=1024,
hash="same_hash",
)
# Mock the repository
scanner_service.sound_repo.get_by_type = AsyncMock(return_value=[existing_sound])
scanner_service.sound_repo.update = AsyncMock()
# Create temporary directory with both original and duplicate files
import tempfile
import os
with tempfile.TemporaryDirectory() as temp_dir:
# Create both files (simulating duplicate content)
original_path = os.path.join(temp_dir, "original.mp3")
duplicate_path = os.path.join(temp_dir, "duplicate.mp3")
with open(original_path, "wb") as f:
original_path = Path(temp_dir) / "original.mp3"
duplicate_path = Path(temp_dir) / "duplicate.mp3"
with original_path.open("wb") as f:
f.write(b"test audio content")
with open(duplicate_path, "wb") as f:
with duplicate_path.open("wb") as f:
f.write(b"test audio content") # Same content = same hash
# Mock file operations
with (
patch("app.services.sound_scanner.get_file_hash", return_value="same_hash"),
@@ -327,14 +325,14 @@ class TestSoundScannerService:
patch("app.services.sound_scanner.get_file_size", return_value=1024),
):
results = await scanner_service.scan_directory(temp_dir, "SDB")
# Should have 1 unchanged (original) and 1 skipped (duplicate)
assert results["skipped"] == 2 # Both files have same hash, both skipped
assert results["duplicates"] == 1 # One duplicate detected
assert results["updated"] == 0
assert results["added"] == 0
assert results["deleted"] == 0
# Check that duplicate was properly detected
skipped_files = [f for f in results["files"] if f["status"] == "skipped"]
duplicate_file = next((f for f in skipped_files if "duplicate" in f["reason"]), None)
@@ -375,14 +373,14 @@ class TestSoundScannerService:
"errors": 0,
"files": [],
}
await scanner_service._sync_audio_file(
temp_path,
"SDB",
None, # existing_sound_by_hash
None, # existing_sound_by_filename
"test_hash",
results,
sync_context = SyncContext(
file_path=temp_path,
sound_type="SDB",
existing_sound_by_hash=None,
existing_sound_by_filename=None,
file_hash="test_hash",
)
await scanner_service._sync_audio_file(sync_context, results)
assert results["added"] == 1
assert results["skipped"] == 0
@@ -439,14 +437,14 @@ class TestSoundScannerService:
"errors": 0,
"files": [],
}
await scanner_service._sync_audio_file(
temp_path,
"SDB",
None, # existing_sound_by_hash (different hash)
existing_sound, # existing_sound_by_filename
"new_hash",
results,
sync_context = SyncContext(
file_path=temp_path,
sound_type="SDB",
existing_sound_by_hash=None,
existing_sound_by_filename=existing_sound,
file_hash="new_hash",
)
await scanner_service._sync_audio_file(sync_context, results)
assert results["updated"] == 1
assert results["added"] == 0
@@ -504,14 +502,14 @@ class TestSoundScannerService:
"errors": 0,
"files": [],
}
await scanner_service._sync_audio_file(
temp_path,
"CUSTOM",
None, # existing_sound_by_hash
None, # existing_sound_by_filename
"custom_hash",
results,
sync_context = SyncContext(
file_path=temp_path,
sound_type="CUSTOM",
existing_sound_by_hash=None,
existing_sound_by_filename=None,
file_hash="custom_hash",
)
await scanner_service._sync_audio_file(sync_context, results)
assert results["added"] == 1
assert results["skipped"] == 0
@@ -533,41 +531,40 @@ class TestSoundScannerService:
@pytest.mark.asyncio
async def test_sync_audio_file_rename_with_normalized_file(
self, test_session, scanner_service
self, test_session, scanner_service,
):
"""Test that renaming a sound file also renames its normalized file."""
# Create temporary directories for testing
from pathlib import Path
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as temp_dir:
temp_dir_path = Path(temp_dir)
# Set up the scanner's normalized directories to use temp dir
scanner_service.normalized_directories = {
"SDB": str(temp_dir_path / "normalized" / "soundboard")
"SDB": str(temp_dir_path / "normalized" / "soundboard"),
}
# Create the normalized directory
normalized_dir = temp_dir_path / "normalized" / "soundboard"
normalized_dir.mkdir(parents=True)
# Create the old normalized file
old_normalized_file = normalized_dir / "old_sound.mp3"
old_normalized_file.write_text("normalized audio content")
# Create the audio files (they need to exist for the scanner)
old_path = temp_dir_path / "old_sound.mp3"
new_path = temp_dir_path / "new_sound.mp3"
# Create a dummy audio file for the new path
new_path.write_bytes(b"fake audio data for testing")
# Mock the audio utility functions since we're using fake files
from unittest.mock import patch
with patch('app.services.sound_scanner.get_audio_duration', return_value=60000), \
patch('app.services.sound_scanner.get_file_size', return_value=2048):
with patch("app.services.sound_scanner.get_audio_duration", return_value=60000), \
patch("app.services.sound_scanner.get_file_size", return_value=2048):
# Create existing sound with normalized file info
existing_sound = Sound(
id=1,
@@ -584,9 +581,9 @@ class TestSoundScannerService:
normalized_hash="normalized_hash",
play_count=5,
is_deletable=False,
is_music=False
is_music=False,
)
results = {
"scanned": 0,
"added": 0,
@@ -597,36 +594,36 @@ class TestSoundScannerService:
"errors": 0,
"files": [],
}
# Mock the sound repository update
scanner_service.sound_repo.update = AsyncMock()
# Simulate rename detection by calling _sync_audio_file
await scanner_service._sync_audio_file(
new_path,
"SDB",
existing_sound, # existing_sound_by_hash (same hash, different filename)
None, # existing_sound_by_filename (no file with new name exists)
"test_hash",
results,
sync_context = SyncContext(
file_path=new_path,
sound_type="SDB",
existing_sound_by_hash=existing_sound,
existing_sound_by_filename=None,
file_hash="test_hash",
)
await scanner_service._sync_audio_file(sync_context, results)
# Verify the results
assert results["updated"] == 1
assert len(results["files"]) == 1
assert results["files"][0]["status"] == "updated"
assert results["files"][0]["reason"] == "file was renamed"
assert "normalized_filename" in results["files"][0]["changes"]
# Verify sound_repo.update was called with normalized filename update
update_call = scanner_service.sound_repo.update.call_args
update_data = update_call[0][1] # Second argument is the update data
assert "filename" in update_data
assert "name" in update_data
assert "normalized_filename" in update_data
assert update_data["normalized_filename"] == "new_sound.mp3"
# Verify the normalized file was actually renamed
new_normalized_file = normalized_dir / "new_sound.mp3"
assert new_normalized_file.exists()
@@ -635,29 +632,29 @@ class TestSoundScannerService:
@pytest.mark.asyncio
async def test_scan_directory_delete_with_normalized_file(
self, test_session, scanner_service
self, test_session, scanner_service,
):
"""Test that deleting a sound also deletes its normalized file."""
# Create temporary directories for testing
from pathlib import Path
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as temp_dir:
temp_dir_path = Path(temp_dir)
scan_dir = temp_dir_path / "sounds"
scan_dir.mkdir()
# Set up the scanner's normalized directories to use temp dir
scanner_service.normalized_directories = {
"SDB": str(temp_dir_path / "normalized" / "soundboard")
"SDB": str(temp_dir_path / "normalized" / "soundboard"),
}
# Create the normalized directory and file
normalized_dir = temp_dir_path / "normalized" / "soundboard"
normalized_dir.mkdir(parents=True)
normalized_file = normalized_dir / "test_sound.mp3"
normalized_file.write_text("normalized audio content")
# Create existing sound with normalized file info
existing_sound = Sound(
id=1,
@@ -674,21 +671,21 @@ class TestSoundScannerService:
normalized_hash="normalized_hash",
play_count=5,
is_deletable=False,
is_music=False
is_music=False,
)
# Mock sound repository methods
scanner_service.sound_repo.get_by_type = AsyncMock(return_value=[existing_sound])
scanner_service.sound_repo.delete = AsyncMock()
# Mock audio utility functions
from unittest.mock import patch
with patch('app.services.sound_scanner.get_audio_duration'), \
patch('app.services.sound_scanner.get_file_size'):
with patch("app.services.sound_scanner.get_audio_duration"), \
patch("app.services.sound_scanner.get_file_size"):
# Run scan with empty directory (should trigger deletion)
results = await scanner_service.scan_directory(str(scan_dir), "SDB")
# Verify the results
assert results["deleted"] == 1
assert results["added"] == 0
@@ -696,9 +693,9 @@ class TestSoundScannerService:
assert len(results["files"]) == 1
assert results["files"][0]["status"] == "deleted"
assert results["files"][0]["reason"] == "file no longer exists"
# Verify sound_repo.delete was called
scanner_service.sound_repo.delete.assert_called_once_with(existing_sound)
# Verify the normalized file was actually deleted
assert not normalized_file.exists()