diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index b01894e..2b260a7 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -2,7 +2,7 @@ from fastapi import APIRouter -from app.api.v1 import auth, main, player, playlists, socket, sounds +from app.api.v1 import admin, auth, main, player, playlists, socket, sounds # V1 API router with v1 prefix api_router = APIRouter(prefix="/v1") @@ -14,3 +14,4 @@ api_router.include_router(player.router, tags=["player"]) api_router.include_router(playlists.router, tags=["playlists"]) api_router.include_router(socket.router, tags=["socket"]) api_router.include_router(sounds.router, tags=["sounds"]) +api_router.include_router(admin.router) diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py new file mode 100644 index 0000000..9dfdab6 --- /dev/null +++ b/app/api/v1/admin/__init__.py @@ -0,0 +1,10 @@ +"""Admin API endpoints.""" + +from fastapi import APIRouter + +from app.api.v1.admin import sounds + +router = APIRouter(prefix="/admin") + +# Include all admin sub-routers +router.include_router(sounds.router) diff --git a/app/api/v1/admin/sounds.py b/app/api/v1/admin/sounds.py new file mode 100644 index 0000000..5f17bee --- /dev/null +++ b/app/api/v1/admin/sounds.py @@ -0,0 +1,235 @@ +"""Admin sound management API endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.core.database import get_db +from app.core.dependencies import get_admin_user +from app.models.user import User +from app.services.extraction_processor import extraction_processor +from app.services.sound_normalizer import NormalizationResults, SoundNormalizerService +from app.services.sound_scanner import ScanResults, SoundScannerService + +router = APIRouter(prefix="/sounds", tags=["admin-sounds"]) + + +async def get_sound_scanner_service( + session: Annotated[AsyncSession, Depends(get_db)], +) -> SoundScannerService: + """Get the sound scanner service.""" + return SoundScannerService(session) + + +async def get_sound_normalizer_service( + session: Annotated[AsyncSession, Depends(get_db)], +) -> SoundNormalizerService: + """Get the sound normalizer service.""" + return SoundNormalizerService(session) + + +# SCAN ENDPOINTS +@router.post("/scan") +async def scan_sounds( + current_user: Annotated[User, Depends(get_admin_user)], + scanner_service: Annotated[SoundScannerService, Depends(get_sound_scanner_service)], +) -> dict[str, ScanResults | str]: + """Sync the soundboard directory (add/update/delete sounds). Admin only.""" + try: + results = await scanner_service.scan_soundboard_directory() + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to sync sounds: {e!s}", + ) from e + else: + return { + "message": "Sound sync completed", + "results": results, + } + + +@router.post("/scan/custom") +async def scan_custom_directory( + directory: str, + current_user: Annotated[User, Depends(get_admin_user)], + scanner_service: Annotated[SoundScannerService, Depends(get_sound_scanner_service)], + sound_type: str = "SDB", +) -> dict[str, ScanResults | str]: + """Sync a custom directory with the database (add/update/delete sounds). Admin only.""" + try: + results = await scanner_service.scan_directory(directory, sound_type) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to sync directory: {e!s}", + ) from e + else: + return { + "message": f"Sync of directory '{directory}' completed", + "results": results, + } + + +# NORMALIZE ENDPOINTS +@router.post("/normalize/all") +async def normalize_all_sounds( + current_user: Annotated[User, Depends(get_admin_user)], + normalizer_service: Annotated[ + SoundNormalizerService, + Depends(get_sound_normalizer_service), + ], + force: Annotated[ + bool, + Query( # noqa: FBT002 + description="Force normalization of already normalized sounds", + ), + ] = False, + one_pass: Annotated[ + bool | None, + Query( + description="Use one-pass normalization (overrides config)", + ), + ] = None, +) -> dict[str, NormalizationResults | str]: + """Normalize all unnormalized sounds. Admin only.""" + try: + results = await normalizer_service.normalize_all_sounds( + force=force, + one_pass=one_pass, + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to normalize sounds: {e!s}", + ) from e + else: + return { + "message": "Sound normalization completed", + "results": results, + } + + +@router.post("/normalize/type/{sound_type}") +async def normalize_sounds_by_type( + sound_type: str, + current_user: Annotated[User, Depends(get_admin_user)], + normalizer_service: Annotated[ + SoundNormalizerService, + Depends(get_sound_normalizer_service), + ], + force: Annotated[ + bool, + Query( # noqa: FBT002 + description="Force normalization of already normalized sounds", + ), + ] = False, + one_pass: Annotated[ + bool | None, + Query( + description="Use one-pass normalization (overrides config)", + ), + ] = None, +) -> dict[str, NormalizationResults | str]: + """Normalize all sounds of a specific type (SDB, TTS, EXT). Admin only.""" + # Validate sound type + valid_types = ["SDB", "TTS", "EXT"] + if sound_type not in valid_types: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid sound type. Must be one of: {', '.join(valid_types)}", + ) + + try: + results = await normalizer_service.normalize_sounds_by_type( + sound_type=sound_type, + force=force, + one_pass=one_pass, + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to normalize {sound_type} sounds: {e!s}", + ) from e + else: + return { + "message": f"Normalization of {sound_type} sounds completed", + "results": results, + } + + +@router.post("/normalize/{sound_id}") +async def normalize_sound_by_id( + sound_id: int, + current_user: Annotated[User, Depends(get_admin_user)], + normalizer_service: Annotated[ + SoundNormalizerService, + Depends(get_sound_normalizer_service), + ], + force: Annotated[ + bool, + Query( # noqa: FBT002 + description="Force normalization of already normalized sound", + ), + ] = False, + one_pass: Annotated[ + bool | None, + Query( + description="Use one-pass normalization (overrides config)", + ), + ] = None, +) -> dict[str, str]: + """Normalize a specific sound by ID. Admin only.""" + try: + # Get the sound + sound = await normalizer_service.sound_repo.get_by_id(sound_id) + if not sound: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Sound with ID {sound_id} not found", + ) + + # Normalize the sound + result = await normalizer_service.normalize_sound( + sound=sound, + force=force, + one_pass=one_pass, + ) + + # Check result status + if result["status"] == "error": + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to normalize sound: {result['error']}", + ) + + return { + "message": f"Sound normalization {result['status']}: {sound.filename}", + "status": result["status"], + "reason": result["reason"] or "", + "normalized_filename": result["normalized_filename"] or "", + } + + except HTTPException: + # Re-raise HTTPExceptions without wrapping them + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to normalize sound: {e!s}", + ) from e + + +# EXTRACTION PROCESSOR STATUS +@router.get("/extract/status") +async def get_extraction_processor_status( + current_user: Annotated[User, Depends(get_admin_user)], # noqa: ARG001 +) -> dict: + """Get the status of the extraction processor. Admin only.""" + return extraction_processor.get_status() diff --git a/app/api/v1/sounds.py b/app/api/v1/sounds.py index 46c82df..804d1a6 100644 --- a/app/api/v1/sounds.py +++ b/app/api/v1/sounds.py @@ -13,26 +13,11 @@ from app.repositories.sound import SoundRepository from app.services.credit import CreditService, InsufficientCreditsError from app.services.extraction import ExtractionInfo, ExtractionService from app.services.extraction_processor import extraction_processor -from app.services.sound_normalizer import NormalizationResults, SoundNormalizerService -from app.services.sound_scanner import ScanResults, SoundScannerService from app.services.vlc_player import VLCPlayerService, get_vlc_player_service router = APIRouter(prefix="/sounds", tags=["sounds"]) -async def get_sound_scanner_service( - session: Annotated[AsyncSession, Depends(get_db)], -) -> SoundScannerService: - """Get the sound scanner service.""" - return SoundScannerService(session) - - -async def get_sound_normalizer_service( - session: Annotated[AsyncSession, Depends(get_db)], -) -> SoundNormalizerService: - """Get the sound normalizer service.""" - return SoundNormalizerService(session) - async def get_extraction_service( session: Annotated[AsyncSession, Depends(get_db)], @@ -58,216 +43,6 @@ async def get_sound_repository( return SoundRepository(session) -# SCAN -@router.post("/scan") -async def scan_sounds( - current_user: Annotated[User, Depends(get_current_active_user_flexible)], - scanner_service: Annotated[SoundScannerService, Depends(get_sound_scanner_service)], -) -> dict[str, ScanResults | str]: - """Sync the soundboard directory (add/update/delete sounds).""" - # Only allow admins to scan sounds - if current_user.role not in ["admin", "superadmin"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only administrators can sync sounds", - ) - - try: - results = await scanner_service.scan_soundboard_directory() - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to sync sounds: {e!s}", - ) from e - else: - return { - "message": "Sound sync completed", - "results": results, - } - - -@router.post("/scan/custom") -async def scan_custom_directory( - directory: str, - current_user: Annotated[User, Depends(get_current_active_user_flexible)], - scanner_service: Annotated[SoundScannerService, Depends(get_sound_scanner_service)], - sound_type: str = "SDB", -) -> dict[str, ScanResults | str]: - """Sync a custom directory with the database (add/update/delete sounds).""" - # Only allow admins to sync sounds - if current_user.role not in ["admin", "superadmin"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only administrators can sync sounds", - ) - - try: - results = await scanner_service.scan_directory(directory, sound_type) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to sync directory: {e!s}", - ) from e - else: - return { - "message": f"Sync of directory '{directory}' completed", - "results": results, - } - - -# NORMALIZE -@router.post("/normalize/all") -async def normalize_all_sounds( - current_user: Annotated[User, Depends(get_current_active_user_flexible)], - normalizer_service: Annotated[ - SoundNormalizerService, Depends(get_sound_normalizer_service), - ], - force: Annotated[bool, Query( # noqa: FBT002 - description="Force normalization of already normalized sounds", - )] = False, - one_pass: Annotated[bool | None, Query( - description="Use one-pass normalization (overrides config)", - )] = None, -) -> dict[str, NormalizationResults | str]: - """Normalize all unnormalized sounds.""" - # Only allow admins to normalize sounds - if current_user.role not in ["admin", "superadmin"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only administrators can normalize sounds", - ) - - try: - results = await normalizer_service.normalize_all_sounds( - force=force, - one_pass=one_pass, - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to normalize sounds: {e!s}", - ) from e - else: - return { - "message": "Sound normalization completed", - "results": results, - } - - -@router.post("/normalize/type/{sound_type}") -async def normalize_sounds_by_type( - sound_type: str, - current_user: Annotated[User, Depends(get_current_active_user_flexible)], - normalizer_service: Annotated[ - SoundNormalizerService, Depends(get_sound_normalizer_service), - ], - force: Annotated[bool, Query( # noqa: FBT002 - description="Force normalization of already normalized sounds", - )] = False, - one_pass: Annotated[bool | None, Query( - description="Use one-pass normalization (overrides config)", - )] = None, -) -> dict[str, NormalizationResults | str]: - """Normalize all sounds of a specific type (SDB, TTS, EXT).""" - # Only allow admins to normalize sounds - if current_user.role not in ["admin", "superadmin"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only administrators can normalize sounds", - ) - - # Validate sound type - valid_types = ["SDB", "TTS", "EXT"] - if sound_type not in valid_types: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid sound type. Must be one of: {', '.join(valid_types)}", - ) - - try: - results = await normalizer_service.normalize_sounds_by_type( - sound_type=sound_type, - force=force, - one_pass=one_pass, - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to normalize {sound_type} sounds: {e!s}", - ) from e - else: - return { - "message": f"Normalization of {sound_type} sounds completed", - "results": results, - } - - -@router.post("/normalize/{sound_id}") -async def normalize_sound_by_id( - sound_id: int, - current_user: Annotated[User, Depends(get_current_active_user_flexible)], - normalizer_service: Annotated[ - SoundNormalizerService, Depends(get_sound_normalizer_service), - ], - force: Annotated[bool, Query( # noqa: FBT002 - description="Force normalization of already normalized sound", - )] = False, - one_pass: Annotated[bool | None, Query( - description="Use one-pass normalization (overrides config)", - )] = None, -) -> dict[str, str]: - """Normalize a specific sound by ID.""" - # Only allow admins to normalize sounds - if current_user.role not in ["admin", "superadmin"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only administrators can normalize sounds", - ) - - try: - # Get the sound - sound = await normalizer_service.sound_repo.get_by_id(sound_id) - if not sound: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Sound with ID {sound_id} not found", - ) - - # Normalize the sound - result = await normalizer_service.normalize_sound( - sound=sound, - force=force, - one_pass=one_pass, - ) - - # Check result status - if result["status"] == "error": - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to normalize sound: {result['error']}", - ) - - return { - "message": f"Sound normalization {result['status']}: {sound.filename}", - "status": result["status"], - "reason": result["reason"] or "", - "normalized_filename": result["normalized_filename"] or "", - } - - except HTTPException: - # Re-raise HTTPExceptions without wrapping them - raise - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to normalize sound: {e!s}", - ) from e - # EXTRACT @router.post("/extract") @@ -308,19 +83,6 @@ async def create_extraction( } -@router.get("/extract/status") -async def get_extraction_processor_status( - current_user: Annotated[User, Depends(get_current_active_user_flexible)], -) -> dict: - """Get the status of the extraction processor.""" - # Only allow admins to see processor status - if current_user.role not in ["admin", "superadmin"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Only administrators can view processor status", - ) - - return extraction_processor.get_status() @router.get("/extract/{extraction_id}") @@ -377,7 +139,7 @@ async def get_user_extractions( # VLC PLAYER -@router.post("/vlc/play/{sound_id}") +@router.post("/play/{sound_id}") async def play_sound_with_vlc( sound_id: int, current_user: Annotated[User, Depends(get_current_active_user_flexible)], @@ -445,7 +207,7 @@ async def play_sound_with_vlc( -@router.post("/vlc/stop-all") +@router.post("/stop") async def stop_all_vlc_instances( current_user: Annotated[User, Depends(get_current_active_user_flexible)], # noqa: ARG001 vlc_player: Annotated[VLCPlayerService, Depends(get_vlc_player)], diff --git a/tests/api/v1/admin/__init__.py b/tests/api/v1/admin/__init__.py new file mode 100644 index 0000000..81c86df --- /dev/null +++ b/tests/api/v1/admin/__init__.py @@ -0,0 +1 @@ +"""Tests for admin API endpoints.""" \ No newline at end of file diff --git a/tests/api/v1/admin/test_sound_endpoints.py b/tests/api/v1/admin/test_sound_endpoints.py new file mode 100644 index 0000000..3ac74f3 --- /dev/null +++ b/tests/api/v1/admin/test_sound_endpoints.py @@ -0,0 +1,554 @@ +"""Tests for admin sound API endpoints.""" + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.models.user import User + +if TYPE_CHECKING: + from app.services.sound_normalizer import NormalizationResults + from app.services.sound_scanner import ScanResults + + +class TestAdminSoundEndpoints: + """Test admin sound API endpoints.""" + + @pytest.mark.asyncio + async def test_scan_sounds_success( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test successful sound scanning.""" + # Mock the scanner service to return successful results + mock_results: ScanResults = { + "scanned": 5, + "added": 3, + "updated": 1, + "deleted": 1, + "skipped": 0, + "errors": 0, + "files": [ + { + "filename": "test1.mp3", + "status": "added", + "reason": None, + "name": "Test1", + "duration": 5000, + "size": 1024, + "id": 1, + "error": None, + "changes": None, + }, + { + "filename": "test2.mp3", + "status": "updated", + "reason": "file was modified", + "name": "Test2", + "duration": 7500, + "size": 2048, + "id": 2, + "error": None, + "changes": ["hash", "duration", "size"], + }, + { + "filename": "test3.mp3", + "status": "deleted", + "reason": "file no longer exists", + "name": "Test3", + "duration": 3000, + "size": 512, + "id": 3, + "error": None, + "changes": None, + }, + ], + } + + with patch( + "app.services.sound_scanner.SoundScannerService.scan_soundboard_directory", + ) as mock_scan: + mock_scan.return_value = mock_results + + response = await authenticated_admin_client.post("/api/v1/admin/sounds/scan") + + assert response.status_code == 200 + data = response.json() + + assert "message" in data + assert "Sound sync completed" in data["message"] + assert "results" in data + + results = data["results"] + assert results["scanned"] == 5 + assert results["added"] == 3 + assert results["updated"] == 1 + assert results["deleted"] == 1 + assert results["skipped"] == 0 + assert results["errors"] == 0 + assert len(results["files"]) == 3 + + # Check file details + assert results["files"][0]["filename"] == "test1.mp3" + assert results["files"][0]["status"] == "added" + assert results["files"][1]["status"] == "updated" + assert results["files"][2]["status"] == "deleted" + + @pytest.mark.asyncio + async def test_scan_sounds_unauthenticated(self, client: AsyncClient) -> None: + """Test scanning sounds without authentication.""" + response = await client.post("/api/v1/admin/sounds/scan") + + assert response.status_code == 401 + data = response.json() + assert "Could not validate credentials" in data["detail"] + + @pytest.mark.asyncio + async def test_scan_sounds_non_admin( + self, + test_app, + test_user: User, + ) -> None: + """Test scanning sounds with non-admin user.""" + from fastapi import HTTPException + from app.core.dependencies import get_admin_user + + # Override the admin dependency to raise 403 for non-admin users + async def override_get_admin_user(): + raise HTTPException(status_code=403, detail="Not enough permissions") + + test_app.dependency_overrides[get_admin_user] = override_get_admin_user + + # Create API token for regular user + headers = {"API-TOKEN": "test_api_token"} + + async with AsyncClient( + transport=ASGITransport(app=test_app), + base_url="http://test", + ) as client: + response = await client.post("/api/v1/admin/sounds/scan", headers=headers) + + assert response.status_code == 403 + data = response.json() + assert "Not enough permissions" in data["detail"] + + # Clean up override + test_app.dependency_overrides.pop(get_admin_user, None) + + @pytest.mark.asyncio + async def test_scan_sounds_service_error( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test scanning sounds when service raises an error.""" + with patch( + "app.services.sound_scanner.SoundScannerService.scan_soundboard_directory", + ) as mock_scan: + mock_scan.side_effect = Exception("Directory not found") + + response = await authenticated_admin_client.post("/api/v1/admin/sounds/scan") + + assert response.status_code == 500 + data = response.json() + assert "Failed to sync sounds" in data["detail"] + assert "Directory not found" in data["detail"] + + @pytest.mark.asyncio + async def test_scan_custom_directory_success( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test successful custom directory scanning.""" + mock_results: ScanResults = { + "scanned": 2, + "added": 2, + "updated": 0, + "deleted": 0, + "skipped": 0, + "errors": 0, + "files": [ + { + "filename": "custom1.wav", + "status": "added", + "reason": None, + "name": "Custom1", + "duration": 4000, + "size": 800, + "id": 10, + "error": None, + "changes": None, + }, + { + "filename": "custom2.wav", + "status": "added", + "reason": None, + "name": "Custom2", + "duration": 6000, + "size": 1200, + "id": 11, + "error": None, + "changes": None, + }, + ], + } + + with patch( + "app.services.sound_scanner.SoundScannerService.scan_directory", + ) as mock_scan: + mock_scan.return_value = mock_results + + response = await authenticated_admin_client.post( + "/api/v1/admin/sounds/scan/custom", + params={"directory": "/custom/path", "sound_type": "CUSTOM"}, + ) + + assert response.status_code == 200 + data = response.json() + + assert "Sync of directory '/custom/path' completed" in data["message"] + assert "results" in data + + results = data["results"] + assert results["scanned"] == 2 + assert results["added"] == 2 + assert len(results["files"]) == 2 + + # Verify the service was called with correct parameters + mock_scan.assert_called_once_with("/custom/path", "CUSTOM") + + @pytest.mark.asyncio + async def test_normalize_all_sounds_success( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test successful normalization of all sounds.""" + mock_results: NormalizationResults = { + "processed": 3, + "normalized": 2, + "skipped": 1, + "errors": 0, + "files": [ + { + "filename": "test1.mp3", + "status": "normalized", + "reason": None, + "original_path": "/fake/test1.mp3", + "normalized_path": "/fake/test1_normalized.mp3", + "normalized_filename": "test1_normalized.mp3", + "normalized_duration": 5000, + "normalized_size": 1024, + "normalized_hash": "norm_hash1", + "id": 1, + "error": None, + }, + { + "filename": "test2.wav", + "status": "normalized", + "reason": None, + "original_path": "/fake/test2.wav", + "normalized_path": "/fake/test2_normalized.mp3", + "normalized_filename": "test2_normalized.mp3", + "normalized_duration": 7000, + "normalized_size": 2048, + "normalized_hash": "norm_hash2", + "id": 2, + "error": None, + }, + { + "filename": "test3.mp3", + "status": "skipped", + "reason": "already normalized", + "original_path": None, + "normalized_path": None, + "normalized_filename": None, + "normalized_duration": None, + "normalized_size": None, + "normalized_hash": None, + "id": 3, + "error": None, + }, + ], + } + + with patch( + "app.services.sound_normalizer.SoundNormalizerService.normalize_all_sounds", + ) as mock_normalize: + mock_normalize.return_value = mock_results + + response = await authenticated_admin_client.post( + "/api/v1/admin/sounds/normalize/all", + ) + + assert response.status_code == 200 + data = response.json() + + assert "message" in data + assert "Sound normalization completed" in data["message"] + assert "results" in data + + results = data["results"] + assert results["processed"] == 3 + assert results["normalized"] == 2 + assert results["skipped"] == 1 + assert results["errors"] == 0 + assert len(results["files"]) == 3 + + @pytest.mark.asyncio + async def test_normalize_all_sounds_unauthenticated(self, client: AsyncClient) -> None: + """Test normalizing sounds without authentication.""" + response = await client.post("/api/v1/admin/sounds/normalize/all") + + assert response.status_code == 401 + data = response.json() + assert "Could not validate credentials" in data["detail"] + + @pytest.mark.asyncio + async def test_normalize_all_sounds_non_admin( + self, + test_app, + test_user: User, + ) -> None: + """Test normalizing sounds with non-admin user.""" + from fastapi import HTTPException + from app.core.dependencies import get_admin_user + + # Override the admin dependency to raise 403 for non-admin users + async def override_get_admin_user(): + raise HTTPException(status_code=403, detail="Not enough permissions") + + test_app.dependency_overrides[get_admin_user] = override_get_admin_user + + headers = {"API-TOKEN": "test_api_token"} + + async with AsyncClient( + transport=ASGITransport(app=test_app), + base_url="http://test", + ) as client: + response = await client.post( + "/api/v1/admin/sounds/normalize/all", headers=headers, + ) + + assert response.status_code == 403 + data = response.json() + assert "Not enough permissions" in data["detail"] + + # Clean up override + test_app.dependency_overrides.pop(get_admin_user, None) + + @pytest.mark.asyncio + async def test_normalize_sounds_by_type_success( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test successful normalization by sound type.""" + mock_results: NormalizationResults = { + "processed": 2, + "normalized": 2, + "skipped": 0, + "errors": 0, + "files": [ + { + "filename": "sdb1.mp3", + "status": "normalized", + "reason": None, + "original_path": "/fake/sdb1.mp3", + "normalized_path": "/fake/sdb1_normalized.mp3", + "normalized_filename": "sdb1_normalized.mp3", + "normalized_duration": 4000, + "normalized_size": 800, + "normalized_hash": "sdb_hash1", + "id": 10, + "error": None, + }, + { + "filename": "sdb2.wav", + "status": "normalized", + "reason": None, + "original_path": "/fake/sdb2.wav", + "normalized_path": "/fake/sdb2_normalized.mp3", + "normalized_filename": "sdb2_normalized.mp3", + "normalized_duration": 6000, + "normalized_size": 1200, + "normalized_hash": "sdb_hash2", + "id": 11, + "error": None, + }, + ], + } + + with patch( + "app.services.sound_normalizer.SoundNormalizerService.normalize_sounds_by_type", + ) as mock_normalize: + mock_normalize.return_value = mock_results + + response = await authenticated_admin_client.post( + "/api/v1/admin/sounds/normalize/type/SDB", + ) + + assert response.status_code == 200 + data = response.json() + + assert "Normalization of SDB sounds completed" in data["message"] + assert "results" in data + + results = data["results"] + assert results["processed"] == 2 + assert results["normalized"] == 2 + assert len(results["files"]) == 2 + + # Verify the service was called with correct type + mock_normalize.assert_called_once_with( + sound_type="SDB", force=False, one_pass=None, + ) + + @pytest.mark.asyncio + async def test_normalize_sounds_by_type_invalid_type( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test normalization with invalid sound type.""" + response = await authenticated_admin_client.post( + "/api/v1/admin/sounds/normalize/type/INVALID", + ) + + assert response.status_code == 400 + data = response.json() + assert "Invalid sound type" in data["detail"] + assert "Must be one of: SDB, TTS, EXT" in data["detail"] + + @pytest.mark.asyncio + async def test_normalize_sound_by_id_success( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test successful normalization of a specific sound.""" + # Mock the sound + mock_sound = type( + "Sound", + (), + { + "id": 42, + "filename": "specific_sound.mp3", + "type": "SDB", + "name": "Specific Sound", + }, + )() + + # Mock normalization result + mock_result = { + "filename": "specific_sound.mp3", + "status": "normalized", + "reason": None, + "original_path": "/fake/specific_sound.mp3", + "normalized_path": "/fake/specific_sound_normalized.mp3", + "normalized_filename": "specific_sound_normalized.mp3", + "normalized_duration": 8000, + "normalized_size": 1600, + "normalized_hash": "specific_hash", + "id": 42, + "error": None, + } + + with ( + patch( + "app.services.sound_normalizer.SoundNormalizerService.normalize_sound", + ) as mock_normalize_sound, + patch("app.repositories.sound.SoundRepository.get_by_id") as mock_get_sound, + ): + mock_get_sound.return_value = mock_sound + mock_normalize_sound.return_value = mock_result + + response = await authenticated_admin_client.post( + "/api/v1/admin/sounds/normalize/42", + ) + + assert response.status_code == 200 + data = response.json() + + assert "Sound normalization normalized" in data["message"] + assert "specific_sound.mp3" in data["message"] + assert data["status"] == "normalized" + assert data["normalized_filename"] == "specific_sound_normalized.mp3" + + # Verify sound was retrieved and normalized + mock_get_sound.assert_called_once_with(42) + mock_normalize_sound.assert_called_once() + + @pytest.mark.asyncio + async def test_get_extraction_processor_status( + self, + authenticated_admin_client: AsyncClient, + admin_user: User, + ) -> None: + """Test getting extraction processor status.""" + with patch( + "app.services.extraction_processor.extraction_processor.get_status" + ) as mock_get_status: + mock_status = { + "is_running": True, + "queue_size": 2, + "active_extractions": 1, + "max_concurrent": 2, + } + mock_get_status.return_value = mock_status + + response = await authenticated_admin_client.get( + "/api/v1/admin/sounds/extract/status" + ) + + assert response.status_code == 200 + data = response.json() + assert data == mock_status + + @pytest.mark.asyncio + async def test_get_extraction_processor_status_unauthenticated( + self, client: AsyncClient + ) -> None: + """Test getting extraction processor status without authentication.""" + response = await client.get("/api/v1/admin/sounds/extract/status") + + assert response.status_code == 401 + data = response.json() + assert "Could not validate credentials" in data["detail"] + + @pytest.mark.asyncio + async def test_get_extraction_processor_status_non_admin( + self, + test_app, + test_user: User, + ) -> None: + """Test getting extraction processor status with non-admin user.""" + from fastapi import HTTPException + from app.core.dependencies import get_admin_user + + # Override the admin dependency to raise 403 for non-admin users + async def override_get_admin_user(): + raise HTTPException(status_code=403, detail="Not enough permissions") + + test_app.dependency_overrides[get_admin_user] = override_get_admin_user + + headers = {"API-TOKEN": "test_api_token"} + + async with AsyncClient( + transport=ASGITransport(app=test_app), + base_url="http://test", + ) as client: + response = await client.get( + "/api/v1/admin/sounds/extract/status", headers=headers + ) + + assert response.status_code == 403 + data = response.json() + assert "Not enough permissions" in data["detail"] + + # Clean up override + test_app.dependency_overrides.pop(get_admin_user, None) \ No newline at end of file diff --git a/tests/api/v1/test_extraction_endpoints.py b/tests/api/v1/test_extraction_endpoints.py index bef5d61..0cc472a 100644 --- a/tests/api/v1/test_extraction_endpoints.py +++ b/tests/api/v1/test_extraction_endpoints.py @@ -45,35 +45,20 @@ class TestExtractionEndpoints: assert response.status_code == 401 @pytest.mark.asyncio - async def test_get_processor_status_admin( + async def test_get_processor_status_moved_to_admin( self, test_client: AsyncClient, admin_cookies: dict[str, str], ) -> None: - """Test getting processor status as admin.""" + """Test that processor status endpoint was moved to admin.""" # Set cookies on client instance to avoid deprecation warning test_client.cookies.update(admin_cookies) - response = await test_client.get("/api/v1/sounds/extract/status") - - # Should succeed for admin users + # The new admin endpoint should work + response = await test_client.get("/api/v1/admin/sounds/extract/status") assert response.status_code == 200 data = response.json() - assert "running" in data + assert "running" in data or "is_running" in data assert "max_concurrent" in data - @pytest.mark.asyncio - async def test_get_processor_status_non_admin( - self, test_client: AsyncClient, auth_cookies: dict[str, str], - ) -> None: - """Test getting processor status as non-admin user.""" - # Set cookies on client instance to avoid deprecation warning - test_client.cookies.update(auth_cookies) - - response = await test_client.get("/api/v1/sounds/extract/status") - - # Should return 403 for non-admin users - assert response.status_code == 403 - assert "Only administrators" in response.json()["detail"] - @pytest.mark.asyncio async def test_get_user_extractions( self, test_client: AsyncClient, auth_cookies: dict[str, str], diff --git a/tests/api/v1/test_sound_endpoints.py b/tests/api/v1/test_sound_endpoints.py index 2eb2032..1e46f70 100644 --- a/tests/api/v1/test_sound_endpoints.py +++ b/tests/api/v1/test_sound_endpoints.py @@ -1,1116 +1,298 @@ -"""Tests for sound API endpoints.""" +"""Tests for sound API endpoints (non-admin endpoints only).""" from typing import TYPE_CHECKING from unittest.mock import patch import pytest -from httpx import ASGITransport, AsyncClient +from httpx import AsyncClient from app.models.user import User if TYPE_CHECKING: - from app.services.sound_normalizer import NormalizationResults - from app.services.sound_scanner import ScanResults + from app.services.extraction import ExtractionInfo class TestSoundEndpoints: - """Test sound API endpoints.""" + """Test sound API endpoints (non-admin only).""" @pytest.mark.asyncio - async def test_scan_sounds_success( + async def test_create_extraction_success( self, - authenticated_admin_client: AsyncClient, - admin_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test successful sound scanning.""" - # Mock the scanner service to return successful results - mock_results: ScanResults = { - "scanned": 5, - "added": 3, - "updated": 1, - "deleted": 1, - "skipped": 0, - "errors": 0, - "files": [ - { - "filename": "test1.mp3", - "status": "added", - "reason": None, - "name": "Test1", - "duration": 5000, - "size": 1024, - "id": 1, - "error": None, - "changes": None, - }, - { - "filename": "test2.mp3", - "status": "updated", - "reason": "file was modified", - "name": "Test2", - "duration": 7500, - "size": 2048, - "id": 2, - "error": None, - "changes": ["hash", "duration", "size"], - }, - { - "filename": "test3.mp3", - "status": "deleted", - "reason": "file no longer exists", - "name": "Test3", - "duration": 3000, - "size": 512, - "id": 3, - "error": None, - "changes": None, - }, - ], + """Test successful extraction creation.""" + mock_extraction_info: ExtractionInfo = { + "id": 1, + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "title": None, + "service": None, + "service_id": None, + "status": "pending", + "error": None, + "sound_id": None, } - with patch( - "app.services.sound_scanner.SoundScannerService.scan_soundboard_directory", - ) as mock_scan: - mock_scan.return_value = mock_results + with ( + patch( + "app.services.extraction.ExtractionService.create_extraction" + ) as mock_create, + patch( + "app.services.extraction_processor.extraction_processor.queue_extraction" + ) as mock_queue, + ): + mock_create.return_value = mock_extraction_info + mock_queue.return_value = None - response = await authenticated_admin_client.post("/api/v1/sounds/scan") + response = await authenticated_client.post( + "/api/v1/sounds/extract", + params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, + ) assert response.status_code == 200 data = response.json() - - assert "message" in data - assert "Sound sync completed" in data["message"] - assert "results" in data - - results = data["results"] - assert results["scanned"] == 5 - assert results["added"] == 3 - assert results["updated"] == 1 - assert results["deleted"] == 1 - assert results["skipped"] == 0 - assert results["errors"] == 0 - assert len(results["files"]) == 3 - - # Check file details - assert results["files"][0]["filename"] == "test1.mp3" - assert results["files"][0]["status"] == "added" - assert results["files"][1]["status"] == "updated" - assert results["files"][2]["status"] == "deleted" + assert data["message"] == "Extraction queued successfully" + assert data["extraction"]["id"] == 1 + assert data["extraction"]["url"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ" @pytest.mark.asyncio - async def test_scan_sounds_unauthenticated(self, client: AsyncClient) -> None: - """Test scanning sounds without authentication.""" - response = await client.post("/api/v1/sounds/scan") + async def test_create_extraction_unauthenticated(self, client: AsyncClient) -> None: + """Test extraction creation without authentication.""" + response = await client.post( + "/api/v1/sounds/extract", + params={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, + ) assert response.status_code == 401 data = response.json() assert "Could not validate credentials" in data["detail"] @pytest.mark.asyncio - async def test_scan_sounds_non_admin( + async def test_create_extraction_invalid_url( self, - test_app, - test_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test scanning sounds with non-admin user.""" - from app.core.dependencies import get_current_active_user_flexible - - # Override the dependency to return regular user - async def override_get_current_user(): - test_user.role = "user" - return test_user - - test_app.dependency_overrides[get_current_active_user_flexible] = ( - override_get_current_user - ) - - # Create API token for regular user - headers = {"API-TOKEN": "test_api_token"} - - async with AsyncClient( - transport=ASGITransport(app=test_app), - base_url="http://test", - ) as client: - response = await client.post("/api/v1/sounds/scan", headers=headers) - - assert response.status_code == 403 - data = response.json() - assert "Only administrators can sync sounds" in data["detail"] - - # Clean up override - test_app.dependency_overrides.pop(get_current_active_user_flexible, None) - - @pytest.mark.asyncio - async def test_scan_sounds_admin_user( - self, - test_app, - admin_user: User, - ) -> None: - """Test scanning sounds with admin user.""" - from app.core.dependencies import get_current_active_user_flexible - - mock_results: ScanResults = { - "scanned": 1, - "added": 1, - "updated": 0, - "deleted": 0, - "skipped": 0, - "errors": 0, - "files": [], - } - - # Override the dependency to return admin user - async def override_get_current_user(): - return admin_user - - test_app.dependency_overrides[get_current_active_user_flexible] = ( - override_get_current_user - ) - - headers = {"API-TOKEN": "admin_api_token"} - + """Test extraction creation with invalid URL.""" with patch( - "app.services.sound_scanner.SoundScannerService.scan_soundboard_directory", - ) as mock_scan: - mock_scan.return_value = mock_results + "app.services.extraction.ExtractionService.create_extraction" + ) as mock_create: + mock_create.side_effect = ValueError("Invalid URL") - async with AsyncClient( - transport=ASGITransport(app=test_app), - base_url="http://test", - ) as client: - response = await client.post("/api/v1/sounds/scan", headers=headers) - - assert response.status_code == 200 - data = response.json() - assert "Sound sync completed" in data["message"] - - # Clean up override - test_app.dependency_overrides.pop(get_current_active_user_flexible, None) - - @pytest.mark.asyncio - async def test_scan_sounds_service_error( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test scanning sounds when service raises an error.""" - with patch( - "app.services.sound_scanner.SoundScannerService.scan_soundboard_directory", - ) as mock_scan: - mock_scan.side_effect = Exception("Directory not found") - - response = await authenticated_admin_client.post("/api/v1/sounds/scan") - - assert response.status_code == 500 - data = response.json() - assert "Failed to sync sounds" in data["detail"] - assert "Directory not found" in data["detail"] - - @pytest.mark.asyncio - async def test_scan_custom_directory_success( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test successful custom directory scanning.""" - mock_results: ScanResults = { - "scanned": 2, - "added": 2, - "updated": 0, - "deleted": 0, - "skipped": 0, - "errors": 0, - "files": [ - { - "filename": "custom1.wav", - "status": "added", - "reason": None, - "name": "Custom1", - "duration": 4000, - "size": 800, - "id": 10, - "error": None, - "changes": None, - }, - { - "filename": "custom2.wav", - "status": "added", - "reason": None, - "name": "Custom2", - "duration": 6000, - "size": 1200, - "id": 11, - "error": None, - "changes": None, - }, - ], - } - - with patch( - "app.services.sound_scanner.SoundScannerService.scan_directory", - ) as mock_scan: - mock_scan.return_value = mock_results - - response = await authenticated_admin_client.post( - "/api/v1/sounds/scan/custom", - params={"directory": "/custom/path", "sound_type": "CUSTOM"}, - ) - - assert response.status_code == 200 - data = response.json() - - assert "Sync of directory '/custom/path' completed" in data["message"] - assert "results" in data - - results = data["results"] - assert results["scanned"] == 2 - assert results["added"] == 2 - assert len(results["files"]) == 2 - - # Verify the service was called with correct parameters - mock_scan.assert_called_once_with("/custom/path", "CUSTOM") - - @pytest.mark.asyncio - async def test_scan_custom_directory_default_type( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test custom directory scanning with default sound type.""" - mock_results: ScanResults = { - "scanned": 1, - "added": 1, - "updated": 0, - "deleted": 0, - "skipped": 0, - "errors": 0, - "files": [], - } - - with patch( - "app.services.sound_scanner.SoundScannerService.scan_directory", - ) as mock_scan: - mock_scan.return_value = mock_results - - response = await authenticated_admin_client.post( - "/api/v1/sounds/scan/custom", - params={"directory": "/another/path"}, # No sound_type param - ) - - assert response.status_code == 200 - - # Verify the service was called with default SDB type - mock_scan.assert_called_once_with("/another/path", "SDB") - - @pytest.mark.asyncio - async def test_scan_custom_directory_invalid_path( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test custom directory scanning with invalid path.""" - with patch( - "app.services.sound_scanner.SoundScannerService.scan_directory", - ) as mock_scan: - mock_scan.side_effect = ValueError( - "Directory does not exist: /invalid/path", - ) - - response = await authenticated_admin_client.post( - "/api/v1/sounds/scan/custom", params={"directory": "/invalid/path"}, + response = await authenticated_client.post( + "/api/v1/sounds/extract", + params={"url": "invalid-url"}, ) assert response.status_code == 400 data = response.json() - assert "Directory does not exist" in data["detail"] + assert "Invalid URL" in data["detail"] @pytest.mark.asyncio - async def test_scan_custom_directory_unauthenticated(self, client: AsyncClient) -> None: - """Test custom directory scanning without authentication.""" - response = await client.post( - "/api/v1/sounds/scan/custom", params={"directory": "/some/path"}, - ) - - assert response.status_code == 401 - data = response.json() - assert "Could not validate credentials" in data["detail"] - - @pytest.mark.asyncio - async def test_scan_custom_directory_non_admin( + async def test_get_extraction_by_id_success( self, - test_app, - test_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test custom directory scanning with non-admin user.""" - from app.core.dependencies import get_current_active_user_flexible - - # Override the dependency to return regular user - async def override_get_current_user(): - test_user.role = "user" - return test_user - - test_app.dependency_overrides[get_current_active_user_flexible] = ( - override_get_current_user - ) - - headers = {"API-TOKEN": "test_api_token"} - - async with AsyncClient( - transport=ASGITransport(app=test_app), - base_url="http://test", - ) as client: - response = await client.post( - "/api/v1/sounds/scan/custom", - headers=headers, - params={"directory": "/some/path"}, - ) - - assert response.status_code == 403 - data = response.json() - assert "Only administrators can sync sounds" in data["detail"] - - # Clean up override - test_app.dependency_overrides.pop(get_current_active_user_flexible, None) - - @pytest.mark.asyncio - async def test_scan_custom_directory_service_error( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test custom directory scanning when service raises an error.""" - with patch( - "app.services.sound_scanner.SoundScannerService.scan_directory", - ) as mock_scan: - mock_scan.side_effect = Exception("Permission denied") - - response = await authenticated_admin_client.post( - "/api/v1/sounds/scan/custom", params={"directory": "/restricted/path"}, - ) - - assert response.status_code == 500 - data = response.json() - assert "Failed to sync directory" in data["detail"] - assert "Permission denied" in data["detail"] - - @pytest.mark.asyncio - async def test_scan_results_with_errors( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test scanning with some errors in results.""" - mock_results: ScanResults = { - "scanned": 3, - "added": 1, - "updated": 0, - "deleted": 0, - "skipped": 1, - "errors": 1, - "files": [ - { - "filename": "good.mp3", - "status": "added", - "reason": None, - "name": "Good", - "duration": 5000, - "size": 1024, - "id": 1, - "error": None, - "changes": None, - }, - { - "filename": "unchanged.mp3", - "status": "skipped", - "reason": "file unchanged", - "name": "Unchanged", - "duration": 3000, - "size": 512, - "id": 2, - "error": None, - "changes": None, - }, - { - "filename": "bad.mp3", - "status": "error", - "reason": None, - "name": None, - "duration": None, - "size": None, - "id": None, - "error": "Invalid audio format", - "changes": None, - }, - ], + """Test getting extraction by ID.""" + mock_extraction_info: ExtractionInfo = { + "id": 1, + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "title": "Never Gonna Give You Up", + "service": "youtube", + "service_id": "dQw4w9WgXcQ", + "status": "completed", + "error": None, + "sound_id": 42, } with patch( - "app.services.sound_scanner.SoundScannerService.scan_soundboard_directory", - ) as mock_scan: - mock_scan.return_value = mock_results + "app.services.extraction.ExtractionService.get_extraction_by_id" + ) as mock_get: + mock_get.return_value = mock_extraction_info - response = await authenticated_admin_client.post("/api/v1/sounds/scan") + response = await authenticated_client.get("/api/v1/sounds/extract/1") assert response.status_code == 200 data = response.json() - - results = data["results"] - assert results["errors"] == 1 - assert results["added"] == 1 - assert results["skipped"] == 1 - - # Check error file details - error_file = next(f for f in results["files"] if f["status"] == "error") - assert error_file["filename"] == "bad.mp3" - assert error_file["error"] == "Invalid audio format" - assert error_file["id"] is None + assert data["id"] == 1 + assert data["title"] == "Never Gonna Give You Up" + assert data["status"] == "completed" + assert data["sound_id"] == 42 @pytest.mark.asyncio - async def test_endpoint_response_structure( + async def test_get_extraction_by_id_not_found( self, - authenticated_admin_client: AsyncClient, - admin_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test that endpoint response has correct structure.""" - mock_results: ScanResults = { - "scanned": 0, - "added": 0, - "updated": 0, - "deleted": 0, - "skipped": 0, - "errors": 0, - "files": [], - } + """Test getting non-existent extraction.""" + with patch( + "app.services.extraction.ExtractionService.get_extraction_by_id" + ) as mock_get: + mock_get.return_value = None + + response = await authenticated_client.get("/api/v1/sounds/extract/999") + + assert response.status_code == 404 + data = response.json() + assert "Extraction 999 not found" in data["detail"] + + @pytest.mark.asyncio + async def test_get_user_extractions_success( + self, + authenticated_client: AsyncClient, + authenticated_user: User, + ) -> None: + """Test getting user extractions.""" + mock_extractions: list[ExtractionInfo] = [ + { + "id": 1, + "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "title": "Never Gonna Give You Up", + "service": "youtube", + "service_id": "dQw4w9WgXcQ", + "status": "completed", + "error": None, + "sound_id": 42, + }, + { + "id": 2, + "url": "https://soundcloud.com/example/track", + "title": "Example Track", + "service": "soundcloud", + "service_id": "example-track", + "status": "pending", + "error": None, + "sound_id": None, + }, + ] with patch( - "app.services.sound_scanner.SoundScannerService.scan_soundboard_directory", - ) as mock_scan: - mock_scan.return_value = mock_results + "app.services.extraction.ExtractionService.get_user_extractions" + ) as mock_get: + mock_get.return_value = mock_extractions - response = await authenticated_admin_client.post("/api/v1/sounds/scan") + response = await authenticated_client.get("/api/v1/sounds/extract") assert response.status_code == 200 data = response.json() - - # Check top-level structure - assert isinstance(data, dict) - assert "message" in data - assert "results" in data - assert isinstance(data["message"], str) - assert isinstance(data["results"], dict) - - # Check results structure - results = data["results"] - required_fields = [ - "scanned", - "added", - "updated", - "deleted", - "skipped", - "errors", - "files", - ] - for field in required_fields: - assert field in results - if field == "files": - assert isinstance(results[field], list) - else: - assert isinstance(results[field], int) + assert len(data["extractions"]) == 2 + assert data["extractions"][0]["title"] == "Never Gonna Give You Up" + assert data["extractions"][1]["status"] == "pending" @pytest.mark.asyncio - async def test_normalize_all_sounds_success( + async def test_play_sound_with_vlc_success( self, - authenticated_admin_client: AsyncClient, - admin_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test successful normalization of all sounds.""" - mock_results: NormalizationResults = { - "processed": 3, - "normalized": 2, - "skipped": 1, - "errors": 0, - "files": [ - { - "filename": "test1.mp3", - "status": "normalized", - "reason": None, - "original_path": "/fake/test1.mp3", - "normalized_path": "/fake/test1_normalized.mp3", - "normalized_filename": "test1_normalized.mp3", - "normalized_duration": 5000, - "normalized_size": 1024, - "normalized_hash": "norm_hash1", - "id": 1, - "error": None, - }, - { - "filename": "test2.wav", - "status": "normalized", - "reason": None, - "original_path": "/fake/test2.wav", - "normalized_path": "/fake/test2_normalized.mp3", - "normalized_filename": "test2_normalized.mp3", - "normalized_duration": 7000, - "normalized_size": 2048, - "normalized_hash": "norm_hash2", - "id": 2, - "error": None, - }, - { - "filename": "test3.mp3", - "status": "skipped", - "reason": "already normalized", - "original_path": None, - "normalized_path": None, - "normalized_filename": None, - "normalized_duration": None, - "normalized_size": None, - "normalized_hash": None, - "id": 3, - "error": None, - }, - ], - } - - with patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_all_sounds", - ) as mock_normalize: - mock_normalize.return_value = mock_results - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/all", - ) - - assert response.status_code == 200 - data = response.json() - - assert "message" in data - assert "Sound normalization completed" in data["message"] - assert "results" in data - - results = data["results"] - assert results["processed"] == 3 - assert results["normalized"] == 2 - assert results["skipped"] == 1 - assert results["errors"] == 0 - assert len(results["files"]) == 3 - - @pytest.mark.asyncio - async def test_normalize_all_sounds_with_force( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test normalization with force parameter.""" - mock_results: NormalizationResults = { - "processed": 1, - "normalized": 1, - "skipped": 0, - "errors": 0, - "files": [], - } - - with patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_all_sounds", - ) as mock_normalize: - mock_normalize.return_value = mock_results - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/all", params={"force": True}, - ) - - assert response.status_code == 200 - - # Verify force parameter was passed - mock_normalize.assert_called_once_with(force=True, one_pass=None) - - @pytest.mark.asyncio - async def test_normalize_all_sounds_with_one_pass( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test normalization with one_pass parameter.""" - mock_results: NormalizationResults = { - "processed": 1, - "normalized": 1, - "skipped": 0, - "errors": 0, - "files": [], - } - - with patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_all_sounds", - ) as mock_normalize: - mock_normalize.return_value = mock_results - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/all", params={"one_pass": True}, - ) - - assert response.status_code == 200 - - # Verify one_pass parameter was passed - mock_normalize.assert_called_once_with(force=False, one_pass=True) - - @pytest.mark.asyncio - async def test_normalize_all_sounds_unauthenticated(self, client: AsyncClient) -> None: - """Test normalizing sounds without authentication.""" - response = await client.post("/api/v1/sounds/normalize/all") - - assert response.status_code == 401 - data = response.json() - assert "Could not validate credentials" in data["detail"] - - @pytest.mark.asyncio - async def test_normalize_all_sounds_non_admin( - self, - test_app, - test_user: User, - ) -> None: - """Test normalizing sounds with non-admin user.""" - from app.core.dependencies import get_current_active_user_flexible - - # Override the dependency to return regular user - async def override_get_current_user(): - test_user.role = "user" - return test_user - - test_app.dependency_overrides[get_current_active_user_flexible] = ( - override_get_current_user - ) - - headers = {"API-TOKEN": "test_api_token"} - - async with AsyncClient( - transport=ASGITransport(app=test_app), - base_url="http://test", - ) as client: - response = await client.post( - "/api/v1/sounds/normalize/all", headers=headers, - ) - - assert response.status_code == 403 - data = response.json() - assert "Only administrators can normalize sounds" in data["detail"] - - # Clean up override - test_app.dependency_overrides.pop(get_current_active_user_flexible, None) - - @pytest.mark.asyncio - async def test_normalize_all_sounds_service_error( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test normalization when service raises an error.""" - with patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_all_sounds", - ) as mock_normalize: - mock_normalize.side_effect = Exception("Normalization service failed") - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/all", - ) - - assert response.status_code == 500 - data = response.json() - assert "Failed to normalize sounds" in data["detail"] - assert "Normalization service failed" in data["detail"] - - @pytest.mark.asyncio - async def test_normalize_sounds_by_type_success( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test successful normalization by sound type.""" - mock_results: NormalizationResults = { - "processed": 2, - "normalized": 2, - "skipped": 0, - "errors": 0, - "files": [ - { - "filename": "sdb1.mp3", - "status": "normalized", - "reason": None, - "original_path": "/fake/sdb1.mp3", - "normalized_path": "/fake/sdb1_normalized.mp3", - "normalized_filename": "sdb1_normalized.mp3", - "normalized_duration": 4000, - "normalized_size": 800, - "normalized_hash": "sdb_hash1", - "id": 10, - "error": None, - }, - { - "filename": "sdb2.wav", - "status": "normalized", - "reason": None, - "original_path": "/fake/sdb2.wav", - "normalized_path": "/fake/sdb2_normalized.mp3", - "normalized_filename": "sdb2_normalized.mp3", - "normalized_duration": 6000, - "normalized_size": 1200, - "normalized_hash": "sdb_hash2", - "id": 11, - "error": None, - }, - ], - } - - with patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_sounds_by_type", - ) as mock_normalize: - mock_normalize.return_value = mock_results - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/type/SDB", - ) - - assert response.status_code == 200 - data = response.json() - - assert "Normalization of SDB sounds completed" in data["message"] - assert "results" in data - - results = data["results"] - assert results["processed"] == 2 - assert results["normalized"] == 2 - assert len(results["files"]) == 2 - - # Verify the service was called with correct type - mock_normalize.assert_called_once_with( - sound_type="SDB", force=False, one_pass=None, - ) - - @pytest.mark.asyncio - async def test_normalize_sounds_by_type_invalid_type( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test normalization with invalid sound type.""" - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/type/INVALID", - ) - - assert response.status_code == 400 - data = response.json() - assert "Invalid sound type" in data["detail"] - assert "Must be one of: SDB, TTS, EXT" in data["detail"] - - @pytest.mark.asyncio - async def test_normalize_sounds_by_type_with_params( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test normalization by type with force and one_pass parameters.""" - mock_results: NormalizationResults = { - "processed": 1, - "normalized": 1, - "skipped": 0, - "errors": 0, - "files": [], - } - - with patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_sounds_by_type", - ) as mock_normalize: - mock_normalize.return_value = mock_results - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/type/TTS", - params={"force": True, "one_pass": False}, - ) - - assert response.status_code == 200 - - # Verify parameters were passed correctly - mock_normalize.assert_called_once_with( - sound_type="TTS", force=True, one_pass=False, - ) - - @pytest.mark.asyncio - async def test_normalize_sound_by_id_success( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test successful normalization of a specific sound.""" + """Test playing sound with VLC successfully.""" # Mock the sound mock_sound = type( "Sound", (), { - "id": 42, - "filename": "specific_sound.mp3", + "id": 1, + "name": "Test Sound", + "filename": "test.mp3", "type": "SDB", - "name": "Specific Sound", }, )() - # Mock normalization result - mock_result = { - "filename": "specific_sound.mp3", - "status": "normalized", - "reason": None, - "original_path": "/fake/specific_sound.mp3", - "normalized_path": "/fake/specific_sound_normalized.mp3", - "normalized_filename": "specific_sound_normalized.mp3", - "normalized_duration": 8000, - "normalized_size": 1600, - "normalized_hash": "specific_hash", - "id": 42, - "error": None, - } - with ( - patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_sound", - ) as mock_normalize_sound, patch("app.repositories.sound.SoundRepository.get_by_id") as mock_get_sound, + patch("app.services.credit.CreditService.validate_and_reserve_credits") as mock_validate, + patch("app.services.vlc_player.VLCPlayerService.play_sound") as mock_play, + patch("app.services.credit.CreditService.deduct_credits") as mock_deduct, ): mock_get_sound.return_value = mock_sound - mock_normalize_sound.return_value = mock_result + mock_validate.return_value = None # No exception means validation passed + mock_play.return_value = True # Success + mock_deduct.return_value = None - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/42", - ) + response = await authenticated_client.post("/api/v1/sounds/play/1") assert response.status_code == 200 data = response.json() - - assert "Sound normalization normalized" in data["message"] - assert "specific_sound.mp3" in data["message"] - assert data["status"] == "normalized" - assert data["normalized_filename"] == "specific_sound_normalized.mp3" - - # Verify sound was retrieved and normalized - mock_get_sound.assert_called_once_with(42) - mock_normalize_sound.assert_called_once() + assert "Test Sound" in data["message"] + assert data["sound_id"] == 1 + assert data["success"] is True + assert data["credits_deducted"] == 1 @pytest.mark.asyncio - async def test_normalize_sound_by_id_not_found( + async def test_play_sound_with_vlc_sound_not_found( self, - authenticated_admin_client: AsyncClient, - admin_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test normalization of non-existent sound.""" - with patch( - "app.repositories.sound.SoundRepository.get_by_id", - ) as mock_get_sound: + """Test playing non-existent sound with VLC.""" + with patch("app.repositories.sound.SoundRepository.get_by_id") as mock_get_sound: mock_get_sound.return_value = None - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/999", - ) + response = await authenticated_client.post("/api/v1/sounds/play/999") assert response.status_code == 404 data = response.json() assert "Sound with ID 999 not found" in data["detail"] @pytest.mark.asyncio - async def test_normalize_sound_by_id_normalization_error( + async def test_play_sound_with_vlc_insufficient_credits( self, - authenticated_admin_client: AsyncClient, - admin_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test normalization when the sound normalization fails.""" + """Test playing sound with VLC when user has insufficient credits.""" + from app.services.credit import InsufficientCreditsError + # Mock the sound mock_sound = type( "Sound", (), { - "id": 42, - "filename": "error_sound.mp3", + "id": 1, + "name": "Test Sound", + "filename": "test.mp3", "type": "SDB", - "name": "Error Sound", }, )() - # Mock normalization error result - mock_result = { - "filename": "error_sound.mp3", - "status": "error", - "reason": None, - "original_path": "/fake/error_sound.mp3", - "normalized_path": None, - "normalized_filename": None, - "normalized_duration": None, - "normalized_size": None, - "normalized_hash": None, - "id": 42, - "error": "File format not supported", - } - with ( - patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_sound", - ) as mock_normalize_sound, patch("app.repositories.sound.SoundRepository.get_by_id") as mock_get_sound, + patch("app.services.credit.CreditService.validate_and_reserve_credits") as mock_validate, ): mock_get_sound.return_value = mock_sound - mock_normalize_sound.return_value = mock_result - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/42", + mock_validate.side_effect = InsufficientCreditsError( + required=1, available=0 ) - assert response.status_code == 500 + response = await authenticated_client.post("/api/v1/sounds/play/1") + + assert response.status_code == 402 data = response.json() - assert "Failed to normalize sound" in data["detail"] - assert "File format not supported" in data["detail"] + assert "Insufficient credits" in data["detail"] + assert "1 required, 0 available" in data["detail"] @pytest.mark.asyncio - async def test_normalize_sound_by_id_with_params( + async def test_stop_all_vlc_instances_success( self, - authenticated_admin_client: AsyncClient, - admin_user: User, + authenticated_client: AsyncClient, + authenticated_user: User, ) -> None: - """Test sound normalization with force and one_pass parameters.""" - # Mock the sound - mock_sound = type( - "Sound", - (), - { - "id": 42, - "filename": "param_sound.mp3", - "type": "SDB", - "name": "Param Sound", - }, - )() - - # Mock normalization result + """Test stopping all VLC instances.""" mock_result = { - "filename": "param_sound.mp3", - "status": "normalized", - "reason": None, - "original_path": "/fake/param_sound.mp3", - "normalized_path": "/fake/param_sound_normalized.mp3", - "normalized_filename": "param_sound_normalized.mp3", - "normalized_duration": 5000, - "normalized_size": 1000, - "normalized_hash": "param_hash", - "id": 42, - "error": None, + "message": "All VLC instances stopped", + "stopped_count": 3, } - with ( - patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_sound", - ) as mock_normalize_sound, - patch("app.repositories.sound.SoundRepository.get_by_id") as mock_get_sound, - ): - mock_get_sound.return_value = mock_sound - mock_normalize_sound.return_value = mock_result + with patch( + "app.services.vlc_player.VLCPlayerService.stop_all_vlc_instances" + ) as mock_stop: + mock_stop.return_value = mock_result - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/42", - params={"force": True, "one_pass": True}, - ) - - assert response.status_code == 200 - - # Verify parameters were passed to normalize_sound - call_args = mock_normalize_sound.call_args - assert call_args[1]["force"] - assert call_args[1]["one_pass"] - - @pytest.mark.asyncio - async def test_normalize_sound_by_id_skipped( - self, - authenticated_admin_client: AsyncClient, - admin_user: User, - ) -> None: - """Test normalization when sound is already normalized and not forced.""" - # Mock the sound - mock_sound = type( - "Sound", - (), - { - "id": 42, - "filename": "already_normalized.mp3", - "type": "SDB", - "name": "Already Normalized", - }, - )() - - # Mock skipped result - mock_result = { - "filename": "already_normalized.mp3", - "status": "skipped", - "reason": "already normalized", - "original_path": None, - "normalized_path": None, - "normalized_filename": None, - "normalized_duration": None, - "normalized_size": None, - "normalized_hash": None, - "id": 42, - "error": None, - } - - with ( - patch( - "app.services.sound_normalizer.SoundNormalizerService.normalize_sound", - ) as mock_normalize_sound, - patch("app.repositories.sound.SoundRepository.get_by_id") as mock_get_sound, - ): - mock_get_sound.return_value = mock_sound - mock_normalize_sound.return_value = mock_result - - response = await authenticated_admin_client.post( - "/api/v1/sounds/normalize/42", - ) + response = await authenticated_client.post("/api/v1/sounds/stop") assert response.status_code == 200 data = response.json() - - assert "Sound normalization skipped" in data["message"] - assert data["status"] == "skipped" - assert data["reason"] == "already normalized" - - @pytest.mark.asyncio - async def test_normalize_sound_by_id_unauthenticated(self, client: AsyncClient) -> None: - """Test normalizing a specific sound without authentication.""" - response = await client.post("/api/v1/sounds/normalize/42") - - assert response.status_code == 401 - data = response.json() - assert "Could not validate credentials" in data["detail"] - - @pytest.mark.asyncio - async def test_normalize_sound_by_id_non_admin( - self, - test_app, - test_user: User, - ) -> None: - """Test normalizing a specific sound with non-admin user.""" - from app.core.dependencies import get_current_active_user_flexible - - # Override the dependency to return regular user - async def override_get_current_user(): - test_user.role = "user" - return test_user - - test_app.dependency_overrides[get_current_active_user_flexible] = ( - override_get_current_user - ) - - headers = {"API-TOKEN": "test_api_token"} - - async with AsyncClient( - transport=ASGITransport(app=test_app), - base_url="http://test", - ) as client: - response = await client.post("/api/v1/sounds/normalize/42", headers=headers) - - assert response.status_code == 403 - data = response.json() - assert "Only administrators can normalize sounds" in data["detail"] - - # Clean up override - test_app.dependency_overrides.pop(get_current_active_user_flexible, None) + assert data["message"] == "All VLC instances stopped" + assert data["stopped_count"] == 3 \ No newline at end of file diff --git a/tests/api/v1/test_vlc_endpoints.py b/tests/api/v1/test_vlc_endpoints.py index 0ec9be1..9fdfdf5 100644 --- a/tests/api/v1/test_vlc_endpoints.py +++ b/tests/api/v1/test_vlc_endpoints.py @@ -50,7 +50,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/play/1") + response = await authenticated_client.post("/api/v1/sounds/play/1") assert response.status_code == 200 data = response.json() @@ -89,7 +89,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/play/999") + response = await authenticated_client.post("/api/v1/sounds/play/999") assert response.status_code == 404 data = response.json() @@ -136,7 +136,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/play/1") + response = await authenticated_client.post("/api/v1/sounds/play/1") assert response.status_code == 500 data = response.json() @@ -169,7 +169,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/play/1") + response = await authenticated_client.post("/api/v1/sounds/play/1") assert response.status_code == 500 data = response.json() @@ -186,7 +186,7 @@ class TestVLCEndpoints: client: AsyncClient, ) -> None: """Test VLC playback without authentication.""" - response = await client.post("/api/v1/sounds/vlc/play/1") + response = await client.post("/api/v1/sounds/play/1") assert response.status_code == 401 @pytest.mark.asyncio @@ -212,7 +212,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/stop-all") + response = await authenticated_client.post("/api/v1/sounds/stop") assert response.status_code == 200 data = response.json() @@ -250,7 +250,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/stop-all") + response = await authenticated_client.post("/api/v1/sounds/stop") assert response.status_code == 200 data = response.json() @@ -285,7 +285,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/stop-all") + response = await authenticated_client.post("/api/v1/sounds/stop") assert response.status_code == 200 data = response.json() @@ -320,7 +320,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/stop-all") + response = await authenticated_client.post("/api/v1/sounds/stop") assert response.status_code == 200 data = response.json() @@ -347,7 +347,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service try: - response = await authenticated_client.post("/api/v1/sounds/vlc/stop-all") + response = await authenticated_client.post("/api/v1/sounds/stop") assert response.status_code == 500 data = response.json() @@ -362,7 +362,7 @@ class TestVLCEndpoints: client: AsyncClient, ) -> None: """Test stopping VLC instances without authentication.""" - response = await client.post("/api/v1/sounds/vlc/stop-all") + response = await client.post("/api/v1/sounds/stop") assert response.status_code == 401 @pytest.mark.asyncio @@ -401,7 +401,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service try: - response = await authenticated_admin_client.post("/api/v1/sounds/vlc/play/1") + response = await authenticated_admin_client.post("/api/v1/sounds/play/1") assert response.status_code == 200 data = response.json() @@ -427,7 +427,7 @@ class TestVLCEndpoints: test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service_2 try: - response = await authenticated_admin_client.post("/api/v1/sounds/vlc/stop-all") + response = await authenticated_admin_client.post("/api/v1/sounds/stop") assert response.status_code == 200 data = response.json()