|
17 | 17 | * Best-artist selection, field normalization and playlist/album/track parsing |
18 | 18 | * getAllSongs pagination, list-libraries shape, and create-or-replace flows |
19 | 19 | * Dispatcher validation and automatic-playlist deletion routing |
| 20 | +* Every provider's create_playlist returns the created playlist on success and |
| 21 | + None on failure, so a caller may test the result instead of assuming success |
20 | 22 | """ |
21 | 23 |
|
| 24 | +import importlib |
| 25 | + |
22 | 26 | import pytest |
23 | 27 | from unittest.mock import Mock, MagicMock, patch |
24 | 28 | import requests |
@@ -2642,6 +2646,103 @@ def test_new_playlist_overflow_failure_returns_none( |
2642 | 2646 | assert jellyfin._create_fresh_playlist('SF', item_ids) is None |
2643 | 2647 |
|
2644 | 2648 |
|
| 2649 | +_DELEGATING_CREATORS = [ |
| 2650 | + ('jellyfin', '_create_fresh_playlist'), |
| 2651 | + ('lyrion', '_create_playlist_batched'), |
| 2652 | + ('navidrome', '_create_playlist_batched'), |
| 2653 | + ('plex', '_create_playlist_batched'), |
| 2654 | +] |
| 2655 | + |
| 2656 | + |
| 2657 | +class TestCreatePlaylistReturnContract: |
| 2658 | + @pytest.mark.parametrize('provider,creator', _DELEGATING_CREATORS) |
| 2659 | + def test_returns_the_created_playlist_rather_than_none(self, provider, creator): |
| 2660 | + module = importlib.import_module(f'tasks.mediaserver.{provider}') |
| 2661 | + created = {'Id': 'p-1', 'Name': 'Mix'} |
| 2662 | + |
| 2663 | + with patch.object(module, creator, return_value=created): |
| 2664 | + assert module.create_playlist('Mix', ['t1']) == created |
| 2665 | + |
| 2666 | + @pytest.mark.parametrize('provider,creator', _DELEGATING_CREATORS) |
| 2667 | + def test_returns_none_when_the_creator_reports_failure(self, provider, creator): |
| 2668 | + module = importlib.import_module(f'tasks.mediaserver.{provider}') |
| 2669 | + |
| 2670 | + with patch.object(module, creator, return_value=None): |
| 2671 | + assert module.create_playlist('Mix', ['t1']) is None |
| 2672 | + |
| 2673 | + |
| 2674 | +class TestJellyfinCreatePlaylist: |
| 2675 | + @patch('tasks.mediaserver.jellyfin._add_items_to_playlist', return_value=True) |
| 2676 | + @patch('tasks.mediaserver.jellyfin.requests') |
| 2677 | + @patch('tasks.mediaserver.jellyfin.config') |
| 2678 | + def test_overflow_tracks_are_added_instead_of_truncated( |
| 2679 | + self, mock_config, mock_requests, mock_add |
| 2680 | + ): |
| 2681 | + from tasks.mediaserver import jellyfin |
| 2682 | + |
| 2683 | + mock_config.JELLYFIN_URL = 'http://jf' |
| 2684 | + mock_config.JELLYFIN_USER_ID = 'admin-user' |
| 2685 | + mock_config.HEADERS = {'Authorization': 'MediaBrowser Token="t"'} |
| 2686 | + post_resp = MagicMock() |
| 2687 | + post_resp.json.return_value = {'Id': 'new-jf', 'Name': 'Mix'} |
| 2688 | + mock_requests.post.return_value = post_resp |
| 2689 | + item_ids = [f'song-{i}' for i in range(jellyfin.JELLYFIN_PLAYLIST_BATCH_SIZE + 5)] |
| 2690 | + |
| 2691 | + result = jellyfin.create_playlist('Mix', item_ids) |
| 2692 | + |
| 2693 | + assert result['Id'] == 'new-jf' |
| 2694 | + posted = mock_requests.post.call_args[1]['json']['Ids'] |
| 2695 | + assert len(posted) == jellyfin.JELLYFIN_PLAYLIST_BATCH_SIZE |
| 2696 | + assert mock_add.call_args[0][1] == item_ids[jellyfin.JELLYFIN_PLAYLIST_BATCH_SIZE:] |
| 2697 | + |
| 2698 | + @patch('tasks.mediaserver.jellyfin.requests') |
| 2699 | + @patch('tasks.mediaserver.jellyfin.config') |
| 2700 | + def test_server_rejection_returns_none_instead_of_silent_success( |
| 2701 | + self, mock_config, mock_requests |
| 2702 | + ): |
| 2703 | + from tasks.mediaserver import jellyfin |
| 2704 | + |
| 2705 | + mock_config.JELLYFIN_URL = 'http://jf' |
| 2706 | + mock_config.JELLYFIN_USER_ID = 'admin-user' |
| 2707 | + mock_config.HEADERS = {'Authorization': 'MediaBrowser Token="t"'} |
| 2708 | + post_resp = MagicMock() |
| 2709 | + post_resp.raise_for_status.side_effect = requests.exceptions.HTTPError('403') |
| 2710 | + mock_requests.post.return_value = post_resp |
| 2711 | + |
| 2712 | + assert jellyfin.create_playlist('Mix', ['t1']) is None |
| 2713 | + |
| 2714 | + |
| 2715 | +class TestEmbyCreatePlaylistReturnContract: |
| 2716 | + @patch('tasks.mediaserver.emby.requests') |
| 2717 | + @patch('tasks.mediaserver.emby.config') |
| 2718 | + def test_returns_the_created_playlist_rather_than_none(self, mock_config, mock_requests): |
| 2719 | + from tasks.mediaserver import emby |
| 2720 | + |
| 2721 | + mock_config.EMBY_URL = 'http://emby' |
| 2722 | + mock_config.EMBY_USER_ID = 'user123' |
| 2723 | + mock_config.EMBY_TOKEN = 'tok' |
| 2724 | + mock_requests.utils.quote.side_effect = lambda value: value |
| 2725 | + mock_requests.post.return_value.json.return_value = {'Id': 'new-emby', 'Name': 'Mix'} |
| 2726 | + |
| 2727 | + assert emby.create_playlist('Mix', ['t1'])['Id'] == 'new-emby' |
| 2728 | + |
| 2729 | + @patch('tasks.mediaserver.emby.requests') |
| 2730 | + @patch('tasks.mediaserver.emby.config') |
| 2731 | + def test_server_rejection_returns_none(self, mock_config, mock_requests): |
| 2732 | + from tasks.mediaserver import emby |
| 2733 | + |
| 2734 | + mock_config.EMBY_URL = 'http://emby' |
| 2735 | + mock_config.EMBY_USER_ID = 'user123' |
| 2736 | + mock_config.EMBY_TOKEN = 'tok' |
| 2737 | + mock_requests.utils.quote.side_effect = lambda value: value |
| 2738 | + mock_requests.exceptions = requests.exceptions |
| 2739 | + mock_requests.post.return_value.raise_for_status.side_effect = ( |
| 2740 | + requests.exceptions.HTTPError('403') |
| 2741 | + ) |
| 2742 | + |
| 2743 | + assert emby.create_playlist('Mix', ['t1']) is None |
| 2744 | + |
| 2745 | + |
2645 | 2746 | class TestEmbyCreateOrReplacePlaylist: |
2646 | 2747 | @patch('tasks.mediaserver.emby.requests') |
2647 | 2748 | @patch('tasks.mediaserver.emby.get_playlist_by_name') |
|
0 commit comments