from unittest.mock import MagicMock, patch import httpx import pytest from arcade.sdk.errors import RetryableToolError, ToolExecutionError from arcade_spotify.tools.constants import RESPONSE_MSGS from arcade_spotify.tools.models import SearchType from arcade_spotify.tools.player import ( adjust_playback_position, get_available_devices, get_currently_playing, get_playback_state, pause_playback, play_artist_by_name, play_track_by_name, resume_playback, skip_to_next_track, skip_to_previous_track, start_tracks_playback_by_id, ) from arcade_spotify.tools.utils import get_url @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, tool_kwargs", [ (adjust_playback_position, {"absolute_position_ms": 10000}), (get_available_devices, {}), (get_currently_playing, {}), (get_playback_state, {}), (pause_playback, {}), (resume_playback, {}), (start_tracks_playback_by_id, {"track_ids": ["1234567890"], "position_ms": 10000}), (skip_to_previous_track, {}), (skip_to_next_track, {}), ], ) async def test_too_many_requests_http_error( tool_function, tool_kwargs, tool_context, mock_httpx_client ): mock_response = MagicMock() mock_response.status_code = 429 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Too Many Requests", request=MagicMock(), response=MagicMock(status_code=429) ) mock_httpx_client.request.return_value = mock_response with pytest.raises(ToolExecutionError): await tool_function(context=tool_context, **tool_kwargs) @pytest.mark.asyncio @patch("arcade_spotify.tools.player.get_playback_state") async def test_adjust_playback_position_absolute_success( mock_get_playback_state, tool_context, mock_httpx_client ): mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await adjust_playback_position(context=tool_context, absolute_position_ms=10000) assert response == RESPONSE_MSGS["playback_position_adjusted"] mock_get_playback_state.assert_not_called() mock_httpx_client.request.assert_called_once_with( "PUT", get_url("player_seek_to_position"), headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, params={"position_ms": 10000}, json=None, ) @pytest.mark.asyncio @patch("arcade_spotify.tools.player.get_playback_state") async def test_adjust_playback_position_relative_success( mock_get_playback_state, tool_context, mock_httpx_client ): mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response mock_get_playback_state.return_value = {"device_id": "1234567890", "progress_ms": 10000} response = await adjust_playback_position(context=tool_context, relative_position_ms=10000) assert response == RESPONSE_MSGS["playback_position_adjusted"] mock_get_playback_state.assert_called_once_with(tool_context) mock_httpx_client.request.assert_called_once_with( "PUT", get_url("player_seek_to_position"), headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, params={"position_ms": 20000}, json=None, ) @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, tool_kwargs", [ # Both arguments provided ( adjust_playback_position, {"absolute_position_ms": 10000, "relative_position_ms": 10000}, ), # No arguments provided ( adjust_playback_position, {}, ), ], ) @patch("arcade_spotify.tools.player.get_playback_state") async def test_adjust_playback_position_wrong_arguments_error( mock_get_playback_state, tool_context, mock_httpx_client, tool_function, tool_kwargs ): with pytest.raises(RetryableToolError): await tool_function(context=tool_context, **tool_kwargs) mock_get_playback_state.assert_not_called() mock_httpx_client.assert_not_called() @pytest.mark.asyncio @patch("arcade_spotify.tools.player.get_playback_state") async def test_adjust_playback_position_no_device_error( mock_get_playback_state, tool_context, mock_httpx_client ): mock_get_playback_state.return_value = {"device_id": None} response = await adjust_playback_position(context=tool_context, relative_position_ms=10000) assert response == RESPONSE_MSGS["no_track_to_adjust_position"] mock_get_playback_state.assert_called_once_with(tool_context) mock_httpx_client.assert_not_called() @pytest.mark.asyncio @patch("arcade_spotify.tools.player.get_playback_state") async def test_adjust_playback_position_not_found_error( mock_get_playback_state, tool_context, mock_httpx_client ): mock_response = MagicMock() mock_response.status_code = 404 mock_httpx_client.request.return_value = mock_response response = await adjust_playback_position(context=tool_context, absolute_position_ms=10000) assert response == RESPONSE_MSGS["no_track_to_adjust_position"] mock_get_playback_state.assert_not_called() mock_httpx_client.request.assert_called_once_with( "PUT", get_url("player_seek_to_position"), headers={"Authorization": f"Bearer {tool_context.authorization.token}"}, params={"position_ms": 10000}, json=None, ) @pytest.mark.asyncio async def test_skip_to_previous_track_success(tool_context, mock_httpx_client): mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await skip_to_previous_track(context=tool_context) assert response == RESPONSE_MSGS["playback_skipped_to_previous_track"] @pytest.mark.asyncio async def test_skip_to_previous_track_not_found_error(tool_context, mock_httpx_client): mock_response = MagicMock() mock_response.status_code = 404 mock_httpx_client.request.return_value = mock_response response = await skip_to_previous_track(context=tool_context) assert response == RESPONSE_MSGS["no_track_to_go_back_to"] @pytest.mark.asyncio async def test_skip_to_next_track_success(tool_context, mock_httpx_client): mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await skip_to_next_track(context=tool_context) assert response == RESPONSE_MSGS["playback_skipped_to_next_track"] @pytest.mark.asyncio async def test_skip_to_next_track_not_found_error(tool_context, mock_httpx_client): mock_response = MagicMock() mock_response.status_code = 404 mock_httpx_client.request.return_value = mock_response response = await skip_to_next_track(context=tool_context) assert response == RESPONSE_MSGS["no_track_to_skip"] @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, mock_is_playing, expected_message", [ (pause_playback, True, RESPONSE_MSGS["playback_paused"]), (resume_playback, False, RESPONSE_MSGS["playback_resumed"]), ], ) @patch("arcade_spotify.tools.player.get_playback_state") async def test_change_playback_state_success( mock_get_playback_state, tool_context, tool_function, mock_is_playing, expected_message, mock_httpx_client, ): mock_get_playback_state.return_value = { "device_id": "1234567890", "is_playing": mock_is_playing, } mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await tool_function(context=tool_context) assert response == expected_message @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, expected_message", [ (pause_playback, RESPONSE_MSGS["no_track_to_pause"]), (resume_playback, RESPONSE_MSGS["no_track_to_resume"]), ], ) @patch("arcade_spotify.tools.player.get_playback_state") async def test_change_playback_state_no_device_running( mock_get_playback_state, tool_context, tool_function, expected_message, mock_httpx_client ): mock_get_playback_state.return_value = {"device_id": None} mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await tool_function(context=tool_context) assert response == expected_message mock_httpx_client.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, mock_is_playing, expected_message", [ (pause_playback, False, RESPONSE_MSGS["track_is_already_paused"]), (resume_playback, True, RESPONSE_MSGS["track_is_already_playing"]), ], ) @patch("arcade_spotify.tools.player.get_playback_state") async def test_change_playback_state_already_set_success( mock_get_playback_state, tool_context, tool_function, mock_is_playing, expected_message, mock_httpx_client, ): mock_get_playback_state.return_value = { "device_id": "1234567890", "is_playing": mock_is_playing, } mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await tool_function(context=tool_context) assert response == expected_message mock_httpx_client.assert_not_called() @pytest.mark.asyncio @patch("arcade_spotify.tools.player.get_available_devices") async def test_start_tracks_playback_by_id_success( mock_get_available_devices, tool_context, mock_httpx_client ): mock_get_available_devices.return_value = { "devices": [ { "id": "1234567890", "is_active": True, "name": "Test Device", "type": "Computer", "is_private_session": False, "is_restricted": False, "supports_volume": True, "volume_percent": 100, } ] } mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await start_tracks_playback_by_id( context=tool_context, track_ids=["1234567890"], position_ms=10000 ) assert response == RESPONSE_MSGS["playback_started"] @pytest.mark.asyncio @patch("arcade_spotify.tools.player.get_available_devices") async def test_start_tracks_playback_by_id_no_active_device( mock_get_available_devices, tool_context, mock_httpx_client ): mock_get_available_devices.return_value = {"devices": []} mock_response = MagicMock() mock_response.status_code = 404 mock_httpx_client.request.return_value = mock_response response = await start_tracks_playback_by_id( context=tool_context, track_ids=["1234567890"], position_ms=10000 ) assert response == RESPONSE_MSGS["no_active_device"] @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, expected_message", [ (get_playback_state, RESPONSE_MSGS["playback_started"]), (get_currently_playing, RESPONSE_MSGS["playback_started"]), ], ) async def test_get_state_success( tool_context, mock_httpx_client, tool_function, expected_message, ): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "device": { "id": "1234567890", "is_active": True, "name": "Test Device", "type": "Computer", }, "currently_playing_type": "track", "is_playing": True, "progress_ms": 10000, "message": "Playback started", } mock_httpx_client.request.return_value = mock_response response = await tool_function(context=tool_context) assert response["device_id"] == "1234567890" assert response["device_name"] == "Test Device" assert response["is_playing"] is True assert response["progress_ms"] == 10000 assert response["message"] == "Playback started" @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function", [get_playback_state, get_currently_playing], ) async def test_get_state_playback_not_active(tool_context, mock_httpx_client, tool_function): mock_response = MagicMock() mock_response.status_code = 204 mock_httpx_client.request.return_value = mock_response response = await tool_function(context=tool_context) assert response["is_playing"] is False @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, tool_kwargs, expected_search_query, expected_limit", [ (play_artist_by_name, {"name": "Test Artist"}, "artist:Test Artist", 5), (play_track_by_name, {"track_name": "Test Track"}, "track:Test Track", 1), ], ) @patch("arcade_spotify.tools.player.start_tracks_playback_by_id") @patch("arcade_spotify.tools.player.search") async def test_play_by_name_success( mock_search, mock_start_tracks_playback_by_id, tool_context, tool_function, tool_kwargs, expected_search_query, expected_limit, mock_httpx_client, ): track_id = "1234567890" mock_search.return_value = {"tracks": {"items": [{"id": track_id, "name": "Test Track"}]}} mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response mock_start_tracks_playback_by_id.return_value = RESPONSE_MSGS["playback_started"] response = await tool_function(context=tool_context, **tool_kwargs) assert response == RESPONSE_MSGS["playback_started"] mock_search.assert_called_once_with( tool_context, expected_search_query, [SearchType.TRACK], expected_limit, ) mock_start_tracks_playback_by_id.assert_called_once_with(tool_context, [track_id]) @pytest.mark.asyncio @pytest.mark.parametrize( "tool_function, tool_kwargs, expected_search_query, expected_limit, expected_message", [ ( play_artist_by_name, {"name": "Test Artist"}, "artist:Test Artist", 5, RESPONSE_MSGS["artist_not_found"].format(artist_name="Test Artist"), ), ( play_track_by_name, {"track_name": "Test Track"}, "track:Test Track", 1, RESPONSE_MSGS["track_not_found"].format(track_name="Test Track"), ), ], ) @patch("arcade_spotify.tools.player.start_tracks_playback_by_id") @patch("arcade_spotify.tools.player.search") async def test_play_by_name_no_tracks_found( mock_search, mock_start_tracks_playback_by_id, tool_context, tool_function, tool_kwargs, expected_search_query, expected_limit, expected_message, mock_httpx_client, ): mock_search.return_value = {"tracks": {"items": []}} mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response mock_start_tracks_playback_by_id.return_value = RESPONSE_MSGS["playback_started"] with pytest.raises(RetryableToolError) as e: await tool_function(context=tool_context, **tool_kwargs) assert e.value.message == expected_message mock_search.assert_called_once_with( tool_context, expected_search_query, [SearchType.TRACK], expected_limit ) mock_start_tracks_playback_by_id.assert_not_called() @pytest.mark.asyncio @patch("arcade_spotify.tools.player.start_tracks_playback_by_id") @patch("arcade_spotify.tools.player.search") async def test_play_track_by_name_with_artist_success( mock_search, mock_start_tracks_playback_by_id, tool_context, mock_httpx_client ): track_id = "1234567890" mock_search.return_value = {"tracks": {"items": [{"id": track_id, "name": "Test Track"}]}} mock_response = MagicMock() mock_response.status_code = 200 mock_httpx_client.request.return_value = mock_response response = await play_track_by_name( context=tool_context, track_name="Test Track", artist_name="Test Artist" ) assert response == str(mock_start_tracks_playback_by_id.return_value) mock_search.assert_called_once_with( tool_context, "track:Test Track artist:Test Artist", [SearchType.TRACK], 1 ) mock_start_tracks_playback_by_id.assert_called_once_with(tool_context, [track_id]) @pytest.mark.asyncio async def test_get_available_devices_success(tool_context, mock_httpx_client): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "devices": [{"id": "1234567890", "name": "Test Device", "type": "Computer"}] } mock_httpx_client.request.return_value = mock_response response = await get_available_devices(context=tool_context) assert response == dict(mock_response.json())