summaryrefslogtreecommitdiff
path: root/tests/test_download_duplicate_prevention.py
blob: d6caee037f80c29956a3bae88cca8c1aaa2ddd13 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
"""
test_download_duplicate_prevention.py

Test suite for download duplicate prevention functionality.

Tests cover:
- Duplicate prevention during downloads
- Integration with HistoryService
- Skipped item logging
- Settings toggle behavior
"""

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from tidalapi import Quality, Track

from tidal_dl_ng.download import Download
from tidal_dl_ng.history import HistoryService


@pytest.fixture
def mock_tidal():
    """Create a mock Tidal object for testing.

    Returns:
        Mock Tidal object with session.
    """
    mock = MagicMock()
    mock.session = MagicMock()
    mock.session.audio_quality = Quality.high_lossless
    mock.switch_to_atmos_session = MagicMock(return_value=True)
    mock.restore_normal_session = MagicMock(return_value=True)
    mock.stream_lock = MagicMock()
    mock.stream_lock.__enter__ = MagicMock(return_value=None)
    mock.stream_lock.__exit__ = MagicMock(return_value=None)
    mock.settings = MagicMock()
    mock.settings.data = MagicMock()
    return mock


@pytest.fixture
def mock_logger():
    """Create a mock logger for testing.

    Returns:
        Mock logger object.
    """
    logger = MagicMock()
    logger.info = MagicMock()
    logger.debug = MagicMock()
    logger.warning = MagicMock()
    logger.error = MagicMock()
    return logger


@pytest.fixture
def temp_download_path(tmp_path: Path) -> Path:
    """Create a temporary download directory.

    Args:
        tmp_path: pytest fixture providing temporary directory.

    Returns:
        Path to temporary download directory.
    """
    download_dir = tmp_path / "downloads"
    download_dir.mkdir()
    return download_dir


@pytest.fixture
def history_service_with_temp(tmp_path: Path, monkeypatch) -> HistoryService:
    """Create a HistoryService with temporary storage.

    Args:
        tmp_path: pytest fixture providing temporary directory.
        monkeypatch: pytest monkeypatch fixture.

    Returns:
        Configured HistoryService instance.
    """
    config_path = tmp_path / "config"
    config_path.mkdir(parents=True, exist_ok=True)

    monkeypatch.setattr("tidal_dl_ng.history.path_config_base", lambda: str(config_path))

    # Reset singleton
    if hasattr(HistoryService, "_instances"):
        HistoryService._instances = {}

    return HistoryService()


@pytest.fixture
def download_instance(mock_tidal, mock_logger, temp_download_path, history_service_with_temp):
    """Create a Download instance for testing.

    Args:
        mock_tidal: Mock Tidal object.
        mock_logger: Mock logger.
        temp_download_path: Temporary download directory.
        history_service_with_temp: HistoryService with temp storage.

    Returns:
        Configured Download instance.
    """
    with patch("tidal_dl_ng.download.HistoryService", return_value=history_service_with_temp):
        download = Download(
            tidal_obj=mock_tidal, path_base=str(temp_download_path), fn_logger=mock_logger, skip_existing=False
        )
        return download


class TestDuplicatePreventionInDownload:
    """Test duplicate prevention during download operations."""

    def test_skip_download_when_track_in_history_and_prevention_enabled(self, download_instance: Download, mock_logger):
        """Test that download is skipped when track is in history and prevention is enabled."""
        # Add track to history
        track_id = "123456"
        download_instance.history_service.add_track_to_history(track_id)
        download_instance.history_service.update_settings(preventDuplicates=True)

        # Create mock track
        mock_track = MagicMock(spec=Track)
        mock_track.id = int(track_id)
        mock_track.name = "Test Track"
        mock_track.available = True
        mock_track.album = MagicMock()
        mock_track.album.name = "Test Album"

        # Attempt download
        with patch.object(download_instance.session, "track", return_value=mock_track):
            result, path = download_instance.item(file_template="{artist_name} - {track_title}", media=mock_track)

        # Should be skipped (result False means skipped)
        assert result is False
        # Logger should have logged the skip message
        assert mock_logger.info.called
        skip_call_found = any(
            "Skipped item" in str(call) or "already in history" in str(call) for call in mock_logger.info.call_args_list
        )
        assert skip_call_found

    def test_download_proceeds_when_prevention_disabled(self, download_instance: Download, mock_logger):
        """Test that download proceeds when prevention is disabled."""
        # Add track to history but disable prevention
        track_id = "789012"
        download_instance.history_service.add_track_to_history(track_id)
        download_instance.history_service.update_settings(preventDuplicates=False)

        # Create mock track
        mock_track = MagicMock(spec=Track)
        mock_track.id = int(track_id)
        mock_track.name = "Test Track 2"
        mock_track.available = True

        # Mock the download process to avoid actual download
        with (
            patch.object(download_instance, "_validate_and_prepare_media", return_value=mock_track),
            patch.object(download_instance, "_prepare_file_paths_and_skip_logic") as mock_paths,
        ):

            mock_paths.return_value = (Path("/fake/path.flac"), ".flac", False, False)

            # The download should not be skipped by duplicate prevention
            # (it will still fail later in the chain, but that's okay for this test)
            result = download_instance.history_service.should_skip_download(track_id)

        # Should NOT be skipped
        assert result is False

    def test_download_proceeds_when_track_not_in_history(self, download_instance: Download):
        """Test that download proceeds when track is not in history."""
        track_id = "999999"
        download_instance.history_service.update_settings(preventDuplicates=True)

        # Track not in history
        result = download_instance.history_service.should_skip_download(track_id)

        assert result is False


class TestHistoryLoggingMessages:
    """Test logging messages for skipped downloads."""

    def test_skip_message_format(self, download_instance: Download, mock_logger):
        """Test that skip message follows the correct format."""
        track_id = "111222"
        download_instance.history_service.add_track_to_history(track_id)
        download_instance.history_service.update_settings(preventDuplicates=True)

        mock_track = MagicMock(spec=Track)
        mock_track.id = int(track_id)
        mock_track.name = "Amazing Song"
        mock_track.artist = MagicMock()
        mock_track.artist.name = "Great Artist"
        mock_track.available = True
        mock_track.album = MagicMock()

        with patch.object(download_instance.session, "track", return_value=mock_track):
            download_instance.item(file_template="{artist_name} - {track_title}", media=mock_track)

        # Check that info logger was called
        assert mock_logger.info.called

        # Verify message contains expected parts
        log_calls = [str(call) for call in mock_logger.info.call_args_list]
        skip_message_found = any("Skipped item" in msg and "already in history" in msg for msg in log_calls)
        assert skip_message_found


class TestHistoryAfterDownload:
    """Test that successful downloads are added to history."""

    @patch("tidal_dl_ng.download.Download._download_and_process_media")
    @patch("tidal_dl_ng.download.Download._adjust_quality_settings")
    @patch("tidal_dl_ng.download.Download._perform_post_processing")
    def test_track_added_to_history_after_successful_download(
        self, mock_post_process, mock_quality, mock_download_process, download_instance: Download
    ):
        """Test that a track is added to history after successful download."""
        # Mock successful download
        mock_download_process.return_value = True
        mock_quality.return_value = (None, None)

        track_id = "333444"
        mock_track = MagicMock(spec=Track)
        mock_track.id = int(track_id)
        mock_track.name = "New Track"
        mock_track.available = True
        mock_track.album = MagicMock()

        with (
            patch.object(download_instance, "_validate_and_prepare_media", return_value=mock_track),
            patch.object(download_instance, "_prepare_file_paths_and_skip_logic") as mock_paths,
        ):

            mock_paths.return_value = (Path("/fake/path.flac"), ".flac", False, False)

            download_instance.item(
                file_template="{artist_name} - {track_title}",
                media=mock_track,
                source_type="playlist",
                source_id="pl-123",
                source_name="Test Playlist",
            )

        # Track should be in history
        assert download_instance.history_service.is_downloaded(track_id)

        # Check metadata
        track_info = download_instance.history_service.get_track_info(track_id)
        assert track_info is not None
        assert track_info["sourceType"] == "playlist"
        assert track_info["sourceId"] == "pl-123"

    @patch("tidal_dl_ng.download.Download._download_and_process_media")
    def test_track_not_added_on_failed_download(self, mock_download_process, download_instance: Download):
        """Test that track is NOT added to history if download fails."""
        # Mock failed download
        mock_download_process.return_value = False

        track_id = "555666"
        mock_track = MagicMock(spec=Track)
        mock_track.id = int(track_id)
        mock_track.available = True
        mock_track.album = MagicMock()

        with (
            patch.object(download_instance, "_validate_and_prepare_media", return_value=mock_track),
            patch.object(download_instance, "_prepare_file_paths_and_skip_logic") as mock_paths,
            patch.object(download_instance, "_adjust_quality_settings") as mock_quality,
        ):

            mock_paths.return_value = (Path("/fake/path.flac"), ".flac", False, False)
            mock_quality.return_value = (None, None)

            download_instance.item(file_template="{artist_name} - {track_title}", media=mock_track)

        # Track should NOT be in history
        assert not download_instance.history_service.is_downloaded(track_id)


class TestSettingsIntegration:
    """Test integration with settings toggle."""

    def test_settings_toggle_affects_duplicate_check(self, download_instance: Download):
        """Test that toggling settings immediately affects duplicate checking."""
        track_id = "777888"
        download_instance.history_service.add_track_to_history(track_id)

        # Enable prevention
        download_instance.history_service.update_settings(preventDuplicates=True)
        assert download_instance.history_service.should_skip_download(track_id) is True

        # Disable prevention
        download_instance.history_service.update_settings(preventDuplicates=False)
        assert download_instance.history_service.should_skip_download(track_id) is False

        # Re-enable
        download_instance.history_service.update_settings(preventDuplicates=True)
        assert download_instance.history_service.should_skip_download(track_id) is True

    def test_settings_persist_across_service_instances(self, tmp_path: Path, monkeypatch):
        """Test that settings persist across service instances."""
        config_path = tmp_path / "config"
        config_path.mkdir(parents=True, exist_ok=True)

        monkeypatch.setattr("tidal_dl_ng.history.path_config_base", lambda: str(config_path))

        # Create first instance and disable prevention
        if hasattr(HistoryService, "_instances"):
            HistoryService._instances = {}

        service1 = HistoryService()
        service1.update_settings(preventDuplicates=False)

        # Create second instance (simulating app restart)
        HistoryService._instances = {}
        service2 = HistoryService()

        # Settings should persist
        assert service2.get_settings()["preventDuplicates"] is False


class TestVideoNotAffectedByHistory:
    """Test that video downloads are not affected by history (only tracks)."""

    def test_video_download_not_checked_against_history(self, download_instance: Download):
        """Test that videos are not checked against download history."""
        from tidalapi import Video

        # This test verifies the type check in the duplicate prevention code
        # Videos should bypass the history check entirely

        mock_video = MagicMock(spec=Video)
        mock_video.id = 123456
        mock_video.name = "Test Video"
        mock_video.available = True

        # Even if we somehow had a video ID in history, it shouldn't matter
        # because the check is `isinstance(media, Track)`
        download_instance.history_service.add_track_to_history("123456")

        with (
            patch.object(download_instance, "_validate_and_prepare_media", return_value=mock_video),
            patch.object(download_instance, "_prepare_file_paths_and_skip_logic") as mock_paths,
            patch.object(download_instance.history_service, "should_skip_download") as mock_skip,
        ):
            mock_paths.return_value = (Path("/fake.mp4"), ".mp4", False, False)

            # The key is that should_skip_download is never called for videos
            # Expected to fail, we're just checking the skip wasn't called
            from contextlib import suppress

            with suppress(Exception):
                download_instance.item(file_template="{artist_name} - {track_title}", media=mock_video)

            # should_skip_download should NOT have been called for video
            assert not mock_skip.called


class TestBulkDownloadWithHistory:
    """Test history tracking in bulk download scenarios."""

    @patch("tidal_dl_ng.download.Download.item")
    def test_multiple_tracks_added_to_history(self, mock_item_download, download_instance: Download):
        """Test that multiple tracks in a bulk download are tracked."""
        # Simulate successful downloads
        mock_item_download.return_value = (True, Path("/fake/path.flac"))

        track_ids = ["t1", "t2", "t3", "t4", "t5"]

        # Manually add tracks to history (simulating what item() would do)
        for track_id in track_ids:
            download_instance.history_service.add_track_to_history(
                track_id=track_id, source_type="album", source_id="al-123", source_name="Test Album"
            )

        # All tracks should be in history
        for track_id in track_ids:
            assert download_instance.history_service.is_downloaded(track_id)

        # Should be grouped under same album
        grouped = download_instance.history_service.get_history_by_source()
        assert "album_al-123" in grouped
        assert len(grouped["album_al-123"]) == 5


if __name__ == "__main__":
    pytest.main([__file__, "-v"])