feat: Enhance WebSocket sound playback with credit validation and refactor related methods
Some checks failed
Backend CI / lint (push) Has been cancelled
Backend CI / test (push) Has been cancelled

This commit is contained in:
JSC
2025-08-19 22:28:54 +02:00
parent a82acfae50
commit b808cfaddf
3 changed files with 100 additions and 135 deletions

View File

@@ -5,6 +5,8 @@ import logging
import socketio import socketio
from app.core.config import settings from app.core.config import settings
from app.core.database import get_session_factory
from app.services.vlc_player import get_vlc_player_service
from app.utils.auth import JWTUtils from app.utils.auth import JWTUtils
from app.utils.cookies import extract_access_token_from_cookies from app.utils.cookies import extract_access_token_from_cookies
@@ -102,44 +104,43 @@ class SocketManager:
@self.sio.event @self.sio.event
async def play_sound(sid: str, data: dict) -> None: async def play_sound(sid: str, data: dict) -> None:
"""Handle play sound event from client.""" """Handle play sound event from client."""
user_id = self.socket_users.get(sid) await self._handle_play_sound(sid, data)
if not user_id: async def _handle_play_sound(self, sid: str, data: dict) -> None:
logger.warning("Play sound request from unknown client %s", sid) """Handle play sound request from WebSocket client."""
return user_id = self.socket_users.get(sid)
sound_id = data.get("sound_id") if not user_id:
if not sound_id: logger.warning("Play sound request from unknown client %s", sid)
logger.warning( return
"Play sound request missing sound_id from user %s",
user_id,
)
return
try: sound_id = data.get("sound_id")
# Import here to avoid circular imports if not sound_id:
from app.services.vlc_player import get_vlc_player_service logger.warning(
from app.core.database import get_session_factory "Play sound request missing sound_id from user %s",
user_id,
)
return
# Get VLC player service with database factory try:
vlc_player = get_vlc_player_service(get_session_factory()) # Get VLC player service with database factory
vlc_player = get_vlc_player_service(get_session_factory())
# Call the service method # Call the service method
await vlc_player.play_sound_with_credits(int(sound_id), int(user_id)) await vlc_player.play_sound_with_credits(int(sound_id), int(user_id))
logger.info("User %s played sound %s via WebSocket", user_id, sound_id) logger.info("User %s played sound %s via WebSocket", user_id, sound_id)
except Exception as e: except Exception as e:
logger.exception( logger.exception(
"Error playing sound %s for user %s: %s", "Error playing sound %s for user %s",
sound_id, sound_id,
user_id, user_id,
e, )
) # Emit error back to user
# Emit error back to user await self.sio.emit(
await self.sio.emit( "sound_play_error",
"sound_play_error", {"sound_id": sound_id, "error": str(e)},
{"sound_id": sound_id, "error": str(e)}, room=sid,
room=sid, )
)
async def send_to_user(self, user_id: str, event: str, data: dict) -> bool: async def send_to_user(self, user_id: str, event: str, data: dict) -> bool:
"""Send a message to a specific user's room.""" """Send a message to a specific user's room."""

View File

@@ -6,6 +6,7 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.logging import get_logger from app.core.logging import get_logger
@@ -14,6 +15,7 @@ from app.models.sound import Sound
from app.models.sound_played import SoundPlayed from app.models.sound_played import SoundPlayed
from app.repositories.sound import SoundRepository from app.repositories.sound import SoundRepository
from app.repositories.user import UserRepository from app.repositories.user import UserRepository
from app.services.credit import CreditService, InsufficientCreditsError
from app.services.socket import socket_manager from app.services.socket import socket_manager
from app.utils.audio import get_sound_file_path from app.utils.audio import get_sound_file_path
@@ -309,7 +311,9 @@ class VLCPlayerService:
await session.close() await session.close()
async def play_sound_with_credits( async def play_sound_with_credits(
self, sound_id: int, user_id: int self,
sound_id: int,
user_id: int,
) -> dict[str, str | int | bool]: ) -> dict[str, str | int | bool]:
"""Play sound with VLC with credit validation and deduction. """Play sound with VLC with credit validation and deduction.
@@ -326,11 +330,8 @@ class VLCPlayerService:
Raises: Raises:
HTTPException: For various error conditions (sound not found, HTTPException: For various error conditions (sound not found,
insufficient credits, VLC failure) insufficient credits, VLC failure)
""" """
from fastapi import HTTPException, status
from app.services.credit import CreditService, InsufficientCreditsError
if not self.db_session_factory: if not self.db_session_factory:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -6,8 +6,7 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from httpx import AsyncClient from httpx import AsyncClient
from app.api.v1.sounds import get_credit_service, get_sound_repository, get_vlc_player from app.api.v1.sounds import get_vlc_player
from app.models.sound import Sound
from app.models.user import User from app.models.user import User
@@ -22,32 +21,21 @@ class TestVLCEndpoints:
authenticated_user: User, authenticated_user: User,
) -> None: ) -> None:
"""Test successful sound playback via VLC.""" """Test successful sound playback via VLC."""
# Set up mocks # Set up mock VLC service
mock_vlc_service = AsyncMock() mock_vlc_service = AsyncMock()
mock_repo = AsyncMock()
mock_credit_service = AsyncMock()
# Set up test data # Set up expected response from the service method
mock_sound = Sound( expected_response = {
id=1, "message": "Sound 'Test Sound' is now playing via VLC",
type="SDB", "sound_id": 1,
name="Test Sound", "sound_name": "Test Sound",
filename="test.mp3", "success": True,
duration=5000, "credits_deducted": 1,
size=1024, }
hash="test_hash", mock_vlc_service.play_sound_with_credits.return_value = expected_response
)
# Configure mocks
mock_repo.get_by_id.return_value = mock_sound
mock_credit_service.validate_and_reserve_credits.return_value = None
mock_credit_service.deduct_credits.return_value = None
mock_vlc_service.play_sound.return_value = True
# Override dependencies # Override dependencies
test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service
test_app.dependency_overrides[get_sound_repository] = lambda: mock_repo
test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service
try: try:
response = await authenticated_client.post("/api/v1/sounds/play/1") response = await authenticated_client.post("/api/v1/sounds/play/1")
@@ -57,15 +45,17 @@ class TestVLCEndpoints:
assert data["sound_id"] == 1 assert data["sound_id"] == 1
assert data["sound_name"] == "Test Sound" assert data["sound_name"] == "Test Sound"
assert "Test Sound" in data["message"] assert "Test Sound" in data["message"]
assert data["success"] is True
assert data["credits_deducted"] == 1
# Verify service calls # Verify service method was called with correct parameters
mock_repo.get_by_id.assert_called_once_with(1) mock_vlc_service.play_sound_with_credits.assert_called_once_with(
mock_vlc_service.play_sound.assert_called_once_with(mock_sound) 1,
authenticated_user.id,
)
finally: finally:
# Clean up dependency overrides (except get_db which is needed for other tests) # Clean up dependency overrides
test_app.dependency_overrides.pop(get_vlc_player, None) test_app.dependency_overrides.pop(get_vlc_player, None)
test_app.dependency_overrides.pop(get_sound_repository, None)
test_app.dependency_overrides.pop(get_credit_service, None)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_play_sound_with_vlc_sound_not_found( async def test_play_sound_with_vlc_sound_not_found(
@@ -75,18 +65,19 @@ class TestVLCEndpoints:
authenticated_user: User, authenticated_user: User,
) -> None: ) -> None:
"""Test VLC playback when sound is not found.""" """Test VLC playback when sound is not found."""
# Set up mocks from fastapi import HTTPException
mock_vlc_service = AsyncMock()
mock_repo = AsyncMock()
mock_credit_service = AsyncMock()
# Configure mocks # Set up mock VLC service
mock_repo.get_by_id.return_value = None mock_vlc_service = AsyncMock()
# Configure mock to raise HTTPException for sound not found
mock_vlc_service.play_sound_with_credits.side_effect = HTTPException(
status_code=404,
detail="Sound with ID 999 not found",
)
# Override dependencies # Override dependencies
test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service
test_app.dependency_overrides[get_sound_repository] = lambda: mock_repo
test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service
try: try:
response = await authenticated_client.post("/api/v1/sounds/play/999") response = await authenticated_client.post("/api/v1/sounds/play/999")
@@ -95,10 +86,8 @@ class TestVLCEndpoints:
data = response.json() data = response.json()
assert "Sound with ID 999 not found" in data["detail"] assert "Sound with ID 999 not found" in data["detail"]
finally: finally:
# Clean up dependency overrides (except get_db which is needed for other tests) # Clean up dependency overrides
test_app.dependency_overrides.pop(get_vlc_player, None) test_app.dependency_overrides.pop(get_vlc_player, None)
test_app.dependency_overrides.pop(get_sound_repository, None)
test_app.dependency_overrides.pop(get_credit_service, None)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_play_sound_with_vlc_launch_failure( async def test_play_sound_with_vlc_launch_failure(
@@ -108,32 +97,19 @@ class TestVLCEndpoints:
authenticated_user: User, authenticated_user: User,
) -> None: ) -> None:
"""Test VLC playback when VLC launch fails.""" """Test VLC playback when VLC launch fails."""
# Set up mocks from fastapi import HTTPException
# Set up mock VLC service
mock_vlc_service = AsyncMock() mock_vlc_service = AsyncMock()
mock_repo = AsyncMock()
mock_credit_service = AsyncMock()
# Set up test data # Configure mock to raise HTTPException for VLC launch failure
mock_sound = Sound( mock_vlc_service.play_sound_with_credits.side_effect = HTTPException(
id=1, status_code=500,
type="SDB", detail="Failed to launch VLC for sound playback",
name="Test Sound",
filename="test.mp3",
duration=5000,
size=1024,
hash="test_hash",
) )
# Configure mocks
mock_repo.get_by_id.return_value = mock_sound
mock_credit_service.validate_and_reserve_credits.return_value = None
mock_credit_service.deduct_credits.return_value = None
mock_vlc_service.play_sound.return_value = False
# Override dependencies # Override dependencies
test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service
test_app.dependency_overrides[get_sound_repository] = lambda: mock_repo
test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service
try: try:
response = await authenticated_client.post("/api/v1/sounds/play/1") response = await authenticated_client.post("/api/v1/sounds/play/1")
@@ -142,10 +118,8 @@ class TestVLCEndpoints:
data = response.json() data = response.json()
assert "Failed to launch VLC for sound playback" in data["detail"] assert "Failed to launch VLC for sound playback" in data["detail"]
finally: finally:
# Clean up dependency overrides (except get_db which is needed for other tests) # Clean up dependency overrides
test_app.dependency_overrides.pop(get_vlc_player, None) test_app.dependency_overrides.pop(get_vlc_player, None)
test_app.dependency_overrides.pop(get_sound_repository, None)
test_app.dependency_overrides.pop(get_credit_service, None)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_play_sound_with_vlc_service_exception( async def test_play_sound_with_vlc_service_exception(
@@ -155,18 +129,16 @@ class TestVLCEndpoints:
authenticated_user: User, authenticated_user: User,
) -> None: ) -> None:
"""Test VLC playback when service raises an exception.""" """Test VLC playback when service raises an exception."""
# Set up mocks # Set up mock VLC service
mock_vlc_service = AsyncMock() mock_vlc_service = AsyncMock()
mock_repo = AsyncMock()
mock_credit_service = AsyncMock()
# Configure mocks # Configure mock to raise a generic exception
mock_repo.get_by_id.side_effect = Exception("Database error") mock_vlc_service.play_sound_with_credits.side_effect = Exception(
"Database error",
)
# Override dependencies # Override dependencies
test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service
test_app.dependency_overrides[get_sound_repository] = lambda: mock_repo
test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service
try: try:
response = await authenticated_client.post("/api/v1/sounds/play/1") response = await authenticated_client.post("/api/v1/sounds/play/1")
@@ -175,10 +147,8 @@ class TestVLCEndpoints:
data = response.json() data = response.json()
assert "Failed to play sound" in data["detail"] assert "Failed to play sound" in data["detail"]
finally: finally:
# Clean up dependency overrides (except get_db which is needed for other tests) # Clean up dependency overrides
test_app.dependency_overrides.pop(get_vlc_player, None) test_app.dependency_overrides.pop(get_vlc_player, None)
test_app.dependency_overrides.pop(get_sound_repository, None)
test_app.dependency_overrides.pop(get_credit_service, None)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_play_sound_with_vlc_unauthenticated( async def test_play_sound_with_vlc_unauthenticated(
@@ -373,32 +343,21 @@ class TestVLCEndpoints:
admin_user: User, admin_user: User,
) -> None: ) -> None:
"""Test VLC endpoints work with admin user.""" """Test VLC endpoints work with admin user."""
# Set up mocks # Set up mock VLC service
mock_vlc_service = AsyncMock() mock_vlc_service = AsyncMock()
mock_repo = AsyncMock()
mock_credit_service = AsyncMock()
# Set up test data # Set up expected response from the service method
mock_sound = Sound( expected_response = {
id=1, "message": "Sound 'Admin Test Sound' is now playing via VLC",
type="SDB", "sound_id": 1,
name="Admin Test Sound", "sound_name": "Admin Test Sound",
filename="admin_test.mp3", "success": True,
duration=3000, "credits_deducted": 1,
size=512, }
hash="admin_hash", mock_vlc_service.play_sound_with_credits.return_value = expected_response
)
# Configure mocks
mock_repo.get_by_id.return_value = mock_sound
mock_credit_service.validate_and_reserve_credits.return_value = None
mock_credit_service.deduct_credits.return_value = None
mock_vlc_service.play_sound.return_value = True
# Override dependencies # Override dependencies
test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service test_app.dependency_overrides[get_vlc_player] = lambda: mock_vlc_service
test_app.dependency_overrides[get_sound_repository] = lambda: mock_repo
test_app.dependency_overrides[get_credit_service] = lambda: mock_credit_service
try: try:
response = await authenticated_admin_client.post("/api/v1/sounds/play/1") response = await authenticated_admin_client.post("/api/v1/sounds/play/1")
@@ -406,11 +365,15 @@ class TestVLCEndpoints:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["sound_name"] == "Admin Test Sound" assert data["sound_name"] == "Admin Test Sound"
# Verify service method was called with admin user ID
mock_vlc_service.play_sound_with_credits.assert_called_once_with(
1,
admin_user.id,
)
finally: finally:
# Clean up dependency overrides (except get_db which is needed for other tests) # Clean up dependency overrides
test_app.dependency_overrides.pop(get_vlc_player, None) test_app.dependency_overrides.pop(get_vlc_player, None)
test_app.dependency_overrides.pop(get_sound_repository, None)
test_app.dependency_overrides.pop(get_credit_service, None)
# Test stop-all endpoint with admin # Test stop-all endpoint with admin
mock_vlc_service_2 = AsyncMock() mock_vlc_service_2 = AsyncMock()