feat: Enhance WebSocket sound playback with credit validation and refactor related methods
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user