import json from pathlib import Path from unittest.mock import patch import pytest from app.library.ItemDTO import Item, ItemDTO def _archive_path(tmp_path: Path) -> str: return str(tmp_path / "archive.txt") class TestItemFormatAndBasics: @patch("app.features.presets.service.Presets.get_instance") def test_format_validates_and_normalizes(self, mock_presets_get): # Preset exists and is not default => allowed mock_presets_get.return_value.has.return_value = True data = { "url": "dQw4w9WgXcQ", # 11-char YouTube ID "preset": "custom", "folder": "media", "cookies": "abc", "template": "%(title)s.%(ext)s", "auto_start": False, "extras": {"k": 1}, "requeued": True, "cli": "--embed-metadata", } with ( patch("app.library.ItemDTO.Item._default_preset", return_value="default"), patch("app.features.ytdlp.utils.arg_converter") as mock_arg_conv, ): mock_arg_conv.return_value = None item = Item.format(data) assert isinstance(item, Item) assert item.url.startswith("https://www.youtube.com/watch?v="), "URL normalized to full YouTube URL" assert item.preset == "custom" assert item.folder == "media" assert item.cookies == "abc" assert item.template == "%(title)s.%(ext)s" assert item.auto_start is False assert item.extras == {"k": 1} assert item.requeued is True assert item.cli == "--embed-metadata" @patch("app.features.presets.service.Presets.get_instance") def test_format_bad_input(self, mock_presets_get): # Missing url with pytest.raises(ValueError, match="url param is required"): Item.format({}) # Invalid preset name (not found) mock_presets_get.return_value.has.return_value = False with ( patch("app.library.ItemDTO.Item._default_preset", return_value="default"), pytest.raises(ValueError, match="Preset 'bad' does not exist"), ): Item.format({"url": "https://example.com", "preset": "bad"}) @patch("app.features.ytdlp.utils.arg_converter") def test_format_cli_parse_error(self, mock_arg_conv): mock_arg_conv.side_effect = RuntimeError("bad cli") with pytest.raises(ValueError, match="Failed to parse command options"): Item.format({"url": "https://example.com", "cli": "--bad"}) def test_item_helpers(self): item = Item(url="https://example.com", extras={"a": 1}, cli="--x") assert item.has_extras() is True assert item.has_cli() is True assert item.get("url") == "https://example.com" assert "url" in item.serialize() assert json.loads(item.json())["url"] == "https://example.com" @patch("app.library.ItemDTO.get_archive_id") def test_item_archive_id_and_is_archived(self, mock_get_id, tmp_path: Path): mock_get_id.return_value = {"archive_id": "x", "id": "x", "ie_key": "k"} file = _archive_path(tmp_path) # get_archive_id item = Item(url="https://example.com") assert item.get_archive_id() == "x" # is_archived uses archive_read through get_archive_file + ytdlp opts with ( patch("app.library.ItemDTO.YTDLPOpts") as mock_opts, patch("app.library.ItemDTO.archive_read") as mock_read, ): mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": file} mock_read.return_value = ["x"] assert item.is_archived() is True class TestItemDTO: @patch("app.library.ItemDTO.get_archive_id") @patch("app.library.ItemDTO.YTDLPOpts") @patch("app.library.ItemDTO.archive_read") def test_post_init_sets_archive_flags(self, mock_read, mock_opts, mock_get_id, tmp_path: Path): # Setup archive id and archive file mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"} mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value file = _archive_path(tmp_path) mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": file} mock_read.return_value = ["arch"] dto = ItemDTO(id="vid", title="t", url="u", folder="f") assert dto.archive_id == "arch" assert dto._archive_file == file assert dto.is_archivable is True assert dto.is_archived is True @patch("app.library.ItemDTO.get_archive_id") @patch("app.library.ItemDTO.YTDLPOpts") @patch("app.library.ItemDTO.archive_read") def test_post_init_keeps_skipped(self, mock_read, mock_opts, mock_get_id, tmp_path: Path): mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"} mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value file = _archive_path(tmp_path) mock_opts.get_instance.return_value.get_all.return_value = { "download_archive": file, "skip_download": True, } mock_read.return_value = ["arch"] dto = ItemDTO(id="vid", title="t", url="u", folder="f") assert dto.download_skipped is False @patch("app.library.ItemDTO.archive_read") def test_serialize_archives_finished(self, mock_read, tmp_path: Path): # Given a finished item with archive info dto = ItemDTO(id="vid", title="t", url="u", folder="f") dto.archive_id = "arch" dto._archive_file = _archive_path(tmp_path) dto.status = "finished" mock_read.return_value = ["arch"] data = dto.serialize() assert data["is_archived"] is True # Removed fields must not be present for key in ItemDTO.removed_fields(): assert key not in data @patch("app.library.ItemDTO.YTDLPOpts") def test_serialize_keeps_skipped(self, mock_opts): mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.get_all.return_value = {} dto = ItemDTO(id="vid", title="t", url="u", folder="f", download_skipped=True) mock_opts.get_instance.reset_mock() data = dto.serialize() assert data["download_skipped"] is True mock_opts.get_instance.assert_not_called() @patch("app.library.ItemDTO.YTDLPOpts") def test_get_ytdlp_opts_uses_preset_and_cli(self, mock_opts): mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value dto = ItemDTO(id="id", title="t", url="u", folder="f", preset="p", cli="--x") mock_opts.get_instance.reset_mock() mock_opts.get_instance.return_value.preset.reset_mock() mock_opts.get_instance.return_value.add_cli.reset_mock() opts = dto.get_ytdlp_opts() mock_opts.get_instance.assert_called_once() mock_opts.get_instance.return_value.preset.assert_called_once_with(name="p") mock_opts.get_instance.return_value.add_cli.assert_called_once() assert opts is mock_opts.get_instance.return_value def test_name_and_ids(self): dto = ItemDTO(id="abc", title="Title", url="u", folder="f") assert dto.name() == 'id="abc", title="Title"' assert isinstance(dto.get_id(), str) def test_archive_add_and_delete_paths(self): dto = ItemDTO(id="id", title="t", url="u", folder="f") assert dto.archive_add() is False, "Precondition not met yet" # Set up to allow add dto.archive_id = "arch" dto._archive_file = "var/tests/archive.txt" dto.is_archivable = True dto.is_archived = False with patch("app.library.ItemDTO.archive_add", return_value=True) as m_add: ok = dto.archive_add() assert ok is True m_add.assert_called_once() # Delete requires is_archived True dto.is_archived = True with patch("app.library.ItemDTO.archive_delete") as m_del: ok2 = dto.archive_delete() assert ok2 is True m_del.assert_called_once() def test_get_file_method(self): """Test ItemDTO get_file method returns correct path.""" # Create ItemDTO with filename but no folder dto = ItemDTO( id="test-id", title="Test Video", url="https://youtube.com/watch?v=test123", folder="", status="finished", filename="test_video.mp4", ) # Test with no filename returns None dto_no_file = ItemDTO( id="test-id-2", title="Test Video 2", url="https://youtube.com/watch?v=test456", folder="", status="finished", ) assert dto_no_file.get_file() is None # Mock get_file function to return success (without custom download_path) with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config: mock_get_file.return_value = ("/downloads/test_video.mp4", 200) mock_config.get_instance.return_value.download_path = "/downloads" result = dto.get_file() assert result == Path("/downloads/test_video.mp4") # Test with folder dto_with_folder = ItemDTO( id="test-id-3", title="Test Video 3", url="https://youtube.com/watch?v=test789", folder="media", status="finished", filename="test_video.mp4", ) with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config: mock_get_file.return_value = ("/downloads/media/test_video.mp4", 200) mock_config.get_instance.return_value.download_path = "/downloads" result = dto_with_folder.get_file() assert result == Path("/downloads/media/test_video.mp4") # Test with file not found with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config: mock_get_file.return_value = ("/downloads/test_video.mp4", 404) mock_config.get_instance.return_value.download_path = "/downloads" result = dto.get_file() assert result is None # Test with exception during file access with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config: mock_get_file.side_effect = ValueError("File path error") mock_config.get_instance.return_value.download_path = "/downloads" result = dto.get_file() assert result is None # Test with custom download_path parameter (Config not imported in this case) with patch("app.library.ItemDTO.get_file") as mock_get_file: mock_get_file.return_value = ("/custom/test_video.mp4", 200) result = dto.get_file(download_path=Path("/custom")) assert result == Path("/custom/test_video.mp4") def test_get_file_sidecar_populates_from_utils(self): with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="sidecar", title="Title", url="u", folder="f") expected_sidecar = { "subtitle": [ { "file": Path("/downloads/video.en.srt"), "lang": "en", "name": "SRT (0) - en", } ] } with ( patch( "app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=Path("/downloads/video.mp4") ) as mock_get_file, patch("app.library.ItemDTO.get_file_sidecar", return_value=expected_sidecar) as mock_utils_sidecar, ): result = dto.get_file_sidecar() mock_get_file.assert_called_once_with(dto) mock_utils_sidecar.assert_called_once_with(Path("/downloads/video.mp4")) assert result is expected_sidecar assert dto.sidecar is expected_sidecar def test_sidecar_no_file(self): with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="sidecar-none", title="Title", url="u", folder="f") existing = {"existing": []} dto.sidecar = existing with ( patch("app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=None) as mock_get_file, patch("app.library.ItemDTO.get_file_sidecar") as mock_utils_sidecar, ): result = dto.get_file_sidecar() mock_get_file.assert_called_once_with(dto) mock_utils_sidecar.assert_not_called() assert result is existing assert dto.sidecar is existing def test_get_preset_hit(self): """Test ItemDTO.get_preset returns the Preset instance.""" from app.features.presets.schemas import Preset mock_preset = Preset(id=1, name="test-preset", cli="--format best") with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="test-preset") with patch("app.features.presets.service.Presets.get_instance") as mock_presets: mock_presets.return_value.get.return_value = mock_preset result = dto.get_preset() mock_presets.return_value.get.assert_called_once_with("test-preset") assert result is mock_preset assert result.name == "test-preset" def test_get_preset_default(self): """Test ItemDTO.get_preset uses 'default' when preset is empty.""" from app.features.presets.schemas import Preset mock_preset = Preset(id=2, name="default", cli="--format best") with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="") with patch("app.features.presets.service.Presets.get_instance") as mock_presets: mock_presets.return_value.get.return_value = mock_preset result = dto.get_preset() mock_presets.return_value.get.assert_called_once_with("default") assert result is mock_preset def test_get_preset_miss(self): """Test ItemDTO.get_preset returns None when preset not found.""" with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="nonexistent") with patch("app.features.presets.service.Presets.get_instance") as mock_presets: mock_presets.return_value.get.return_value = None result = dto.get_preset() mock_presets.return_value.get.assert_called_once_with("nonexistent") assert result is None class TestItemAddExtras: def test_add_extras_to_empty_dict(self): """Test adding extras when extras dict is empty.""" item = Item(url="https://example.com") item.extras = {} item.add_extras("key1", "value1") assert item.extras["key1"] == "value1" def test_add_extras_none(self): """Test adding extras when extras is None.""" item = Item(url="https://example.com") item.extras = None item.add_extras("key1", "value1") assert item.extras == {"key1": "value1"} def test_add_extras_to_existing_dict(self): """Test adding extras to an existing extras dict.""" item = Item(url="https://example.com", extras={"existing": "data"}) item.add_extras("new_key", "new_value") assert item.extras["existing"] == "data" assert item.extras["new_key"] == "new_value" def test_add_extras_overwrites_existing_key(self): """Test that adding extras overwrites existing keys.""" item = Item(url="https://example.com", extras={"key1": "old_value"}) item.add_extras("key1", "new_value") assert item.extras["key1"] == "new_value" def test_add_extras_with_various_types(self): """Test adding extras with various data types.""" item = Item(url="https://example.com") item.extras = {} item.add_extras("string", "value") item.add_extras("number", 42) item.add_extras("boolean", True) item.add_extras("list", [1, 2, 3]) item.add_extras("dict", {"nested": "data"}) assert item.extras["string"] == "value" assert item.extras["number"] == 42 assert item.extras["boolean"] is True assert item.extras["list"] == [1, 2, 3] assert item.extras["dict"] == {"nested": "data"}