More tests
This commit is contained in:
parent
f490c78179
commit
393cc6729c
5 changed files with 2493 additions and 19 deletions
|
|
@ -10,6 +10,7 @@ from copy import deepcopy
|
|||
from datetime import UTC, datetime
|
||||
from email.utils import formatdate
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yt_dlp.utils
|
||||
|
||||
|
|
@ -128,10 +129,10 @@ class Download:
|
|||
"Time when the download started."
|
||||
self.queue_time: datetime = datetime.now(tz=UTC)
|
||||
"Time when the download was queued."
|
||||
self.logs = logs if logs else []
|
||||
self.logs: list[str] = logs if logs else []
|
||||
"Logs from yt-dlp."
|
||||
|
||||
def _progress_hook(self, data: dict):
|
||||
def _progress_hook(self, data: dict) -> None:
|
||||
if self.debug:
|
||||
d_copy: dict = deepcopy(data)
|
||||
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
|
||||
|
|
@ -147,7 +148,7 @@ class Download:
|
|||
}
|
||||
)
|
||||
|
||||
def _postprocessor_hook(self, data: dict):
|
||||
def _postprocessor_hook(self, data: dict) -> None:
|
||||
if self.debug:
|
||||
d_copy: dict = deepcopy(data)
|
||||
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
|
||||
|
|
@ -159,7 +160,7 @@ class Download:
|
|||
if "__finaldir" in data.get("info_dict", {}) and "filepath" in data.get("info_dict", {}):
|
||||
filename = str(Path(data["info_dict"]["__finaldir"]) / Path(data["info_dict"]["filepath"]).name)
|
||||
else:
|
||||
filename = data.get("info_dict", {}).get("filepath", data.get("filename"))
|
||||
filename: str | None = data.get("info_dict", {}).get("filepath", data.get("filename"))
|
||||
|
||||
self.logger.debug(f"Final filename: '{filename}'.")
|
||||
self.status_queue.put({"id": self.id, "action": "moved", "status": "finished", "final_name": filename})
|
||||
|
|
@ -168,13 +169,13 @@ class Download:
|
|||
dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields}
|
||||
self.status_queue.put({"id": self.id, "action": "postprocessing", **dataDict, "status": "postprocessing"})
|
||||
|
||||
def post_hooks(self, filename: str | None = None):
|
||||
def post_hooks(self, filename: str | None = None) -> None:
|
||||
if not filename:
|
||||
return
|
||||
|
||||
self.status_queue.put({"id": self.id, "filename": filename})
|
||||
|
||||
def _download(self):
|
||||
def _download(self) -> None:
|
||||
if not self._notify:
|
||||
self._notify = EventBus.get_instance()
|
||||
|
||||
|
|
@ -227,11 +228,11 @@ class Download:
|
|||
|
||||
load_cookies(cookie_file)
|
||||
except Exception as e:
|
||||
err_msg = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'."
|
||||
err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'."
|
||||
self.logger.error(err_msg)
|
||||
raise ValueError(err_msg) from e
|
||||
|
||||
# Safe-guard incase downloading take too long and the info expires.
|
||||
# Safe-guard in-case downloading take too long and the info expires.
|
||||
if self.info_dict and isinstance(self.info_dict, dict) and self.download_info_expires > 0:
|
||||
_ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None))
|
||||
_ts = datetime.fromtimestamp(_ts, tz=UTC) if _ts else None
|
||||
|
|
@ -242,14 +243,14 @@ class Download:
|
|||
if not self.info_dict or not isinstance(self.info_dict, dict):
|
||||
self.logger.info(f"Extracting info for '{self.info.url}'.")
|
||||
self.logs = []
|
||||
ie_params = params.copy()
|
||||
ie_params: dict = params.copy()
|
||||
ie_params["callback"] = {
|
||||
"func": lambda _, msg: self.logs.append(msg),
|
||||
"level": logging.WARNING,
|
||||
"name": "callback-logger",
|
||||
}
|
||||
|
||||
info = extract_info(
|
||||
info: dict = extract_info(
|
||||
config=params,
|
||||
url=self.info.url,
|
||||
debug=self.debug,
|
||||
|
|
@ -337,8 +338,8 @@ class Download:
|
|||
f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
self.status_queue = Config.get_manager().Queue()
|
||||
async def start(self) -> None:
|
||||
self.status_queue: multiprocessing.Queue[Any] = Config.get_manager().Queue()
|
||||
|
||||
# Create temp dir for each download.
|
||||
if not self.temp_disabled:
|
||||
|
|
@ -479,7 +480,7 @@ class Download:
|
|||
|
||||
return False
|
||||
|
||||
def delete_temp(self, by_pass: bool = False):
|
||||
def delete_temp(self, by_pass: bool = False) -> None:
|
||||
if self.temp_disabled or self.temp_keep is True or not self.temp_path:
|
||||
return
|
||||
|
||||
|
|
@ -508,7 +509,7 @@ class Download:
|
|||
else:
|
||||
self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.")
|
||||
|
||||
async def _process_status_update(self, status):
|
||||
async def _process_status_update(self, status) -> None:
|
||||
if status.get("id") != self.id or len(status) < 2:
|
||||
self.logger.warning(f"Received invalid status update. {status}")
|
||||
return
|
||||
|
|
@ -587,7 +588,7 @@ class Download:
|
|||
if not self.final_update or fl:
|
||||
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
|
||||
|
||||
async def progress_update(self):
|
||||
async def progress_update(self) -> None:
|
||||
"""
|
||||
Update status of download task and notify the client.
|
||||
"""
|
||||
|
|
@ -638,11 +639,11 @@ class Download:
|
|||
|
||||
return False
|
||||
|
||||
def __getstate__(self):
|
||||
state = self.__dict__.copy()
|
||||
def __getstate__(self) -> dict[str, Any]:
|
||||
state: dict[str, Any] = self.__dict__.copy()
|
||||
|
||||
# Exclude (unpickleable) keys during pickling, this issue arise mostly on Windows.
|
||||
excluded_keys = ("_notify",)
|
||||
excluded_keys: tuple[str] = ("_notify",)
|
||||
for key in excluded_keys:
|
||||
if key in state:
|
||||
state[key] = None
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class YTDLPOpts:
|
|||
YTDLPOpts: The instance of the class
|
||||
|
||||
"""
|
||||
if not args or len(args) < 2 or not isinstance(args, str):
|
||||
if not args or not isinstance(args, str) or len(args) < 2:
|
||||
return self
|
||||
|
||||
try:
|
||||
|
|
|
|||
1101
app/tests/test_notifications.py
Normal file
1101
app/tests/test_notifications.py
Normal file
File diff suppressed because it is too large
Load diff
864
app/tests/test_tasks.py
Normal file
864
app/tests/test_tasks.py
Normal file
|
|
@ -0,0 +1,864 @@
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.Tasks import Task, Tasks
|
||||
|
||||
|
||||
class TestTask:
|
||||
"""Test the Task dataclass."""
|
||||
|
||||
def test_task_creation_with_required_fields(self):
|
||||
"""Test creating a task with only required fields."""
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/video")
|
||||
|
||||
assert task.id == "test_id"
|
||||
assert task.name == "test_task"
|
||||
assert task.url == "https://example.com/video"
|
||||
assert task.folder == ""
|
||||
assert task.preset == ""
|
||||
assert task.timer == ""
|
||||
assert task.template == ""
|
||||
assert task.cli == ""
|
||||
assert task.auto_start is True
|
||||
assert task.handler_enabled is True
|
||||
|
||||
def test_task_creation_with_all_fields(self):
|
||||
"""Test creating a task with all fields specified."""
|
||||
task = Task(
|
||||
id="test_id",
|
||||
name="test_task",
|
||||
url="https://example.com/video",
|
||||
folder="test_folder",
|
||||
preset="test_preset",
|
||||
timer="0 */6 * * *",
|
||||
template="%(title)s.%(ext)s",
|
||||
cli="--extract-flat",
|
||||
auto_start=False,
|
||||
handler_enabled=False,
|
||||
)
|
||||
|
||||
assert task.id == "test_id"
|
||||
assert task.name == "test_task"
|
||||
assert task.url == "https://example.com/video"
|
||||
assert task.folder == "test_folder"
|
||||
assert task.preset == "test_preset"
|
||||
assert task.timer == "0 */6 * * *"
|
||||
assert task.template == "%(title)s.%(ext)s"
|
||||
assert task.cli == "--extract-flat"
|
||||
assert task.auto_start is False
|
||||
assert task.handler_enabled is False
|
||||
|
||||
def test_task_serialize(self):
|
||||
"""Test task serialization to dictionary."""
|
||||
task = Task(
|
||||
id="test_id",
|
||||
name="test_task",
|
||||
url="https://example.com/video",
|
||||
preset="test_preset",
|
||||
)
|
||||
|
||||
serialized = task.serialize()
|
||||
|
||||
assert isinstance(serialized, dict)
|
||||
assert serialized["id"] == "test_id"
|
||||
assert serialized["name"] == "test_task"
|
||||
assert serialized["url"] == "https://example.com/video"
|
||||
assert serialized["preset"] == "test_preset"
|
||||
assert serialized["folder"] == ""
|
||||
assert serialized["auto_start"] is True
|
||||
|
||||
@patch("app.library.Tasks.Encoder")
|
||||
def test_task_json(self, mock_encoder):
|
||||
"""Test task JSON serialization."""
|
||||
mock_encoder_instance = Mock()
|
||||
mock_encoder_instance.encode.return_value = '{"id": "test_id"}'
|
||||
mock_encoder.return_value = mock_encoder_instance
|
||||
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/video")
|
||||
|
||||
json_str = task.json()
|
||||
|
||||
assert json_str == '{"id": "test_id"}'
|
||||
mock_encoder_instance.encode.assert_called_once()
|
||||
|
||||
def test_task_get_method(self):
|
||||
"""Test task get method for accessing fields."""
|
||||
task = Task(
|
||||
id="test_id",
|
||||
name="test_task",
|
||||
url="https://example.com/video",
|
||||
preset="test_preset",
|
||||
)
|
||||
|
||||
assert task.get("id") == "test_id"
|
||||
assert task.get("name") == "test_task"
|
||||
assert task.get("preset") == "test_preset"
|
||||
assert task.get("nonexistent") is None
|
||||
assert task.get("nonexistent", "default_value") == "default_value"
|
||||
|
||||
@patch("app.library.Tasks.YTDLPOpts")
|
||||
def test_task_get_ytdlp_opts_without_preset_or_cli(self, mock_ytdlp_opts):
|
||||
"""Test getting YTDLPOpts without preset or CLI options."""
|
||||
mock_opts_instance = Mock()
|
||||
mock_ytdlp_opts.get_instance.return_value = mock_opts_instance
|
||||
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/video")
|
||||
|
||||
result = task.get_ytdlp_opts()
|
||||
|
||||
assert result == mock_opts_instance
|
||||
mock_ytdlp_opts.get_instance.assert_called_once()
|
||||
|
||||
@patch("app.library.Tasks.YTDLPOpts")
|
||||
def test_task_get_ytdlp_opts_with_preset_and_cli(self, mock_ytdlp_opts):
|
||||
"""Test getting YTDLPOpts with preset and CLI options."""
|
||||
mock_opts_instance = Mock()
|
||||
mock_preset_opts = Mock()
|
||||
mock_final_opts = Mock()
|
||||
|
||||
mock_opts_instance.preset.return_value = mock_preset_opts
|
||||
mock_preset_opts.add_cli.return_value = mock_final_opts
|
||||
mock_ytdlp_opts.get_instance.return_value = mock_opts_instance
|
||||
|
||||
task = Task(
|
||||
id="test_id",
|
||||
name="test_task",
|
||||
url="https://example.com/video",
|
||||
preset="test_preset",
|
||||
cli="--extract-flat",
|
||||
)
|
||||
|
||||
result = task.get_ytdlp_opts()
|
||||
|
||||
assert result == mock_final_opts
|
||||
mock_opts_instance.preset.assert_called_once_with(name="test_preset")
|
||||
mock_preset_opts.add_cli.assert_called_once_with("--extract-flat", from_user=True)
|
||||
|
||||
@patch("app.library.Tasks.archive_add")
|
||||
@patch("app.library.Tasks.extract_info")
|
||||
def test_task_mark_success(self, mock_extract_info, mock_archive_add):
|
||||
"""Test successful task marking."""
|
||||
# Mock extract_info to return playlist data
|
||||
mock_extract_info.return_value = {
|
||||
"_type": "playlist",
|
||||
"entries": [
|
||||
{
|
||||
"_type": "video",
|
||||
"id": "video1",
|
||||
"ie_key": "Youtube",
|
||||
},
|
||||
{
|
||||
"_type": "video",
|
||||
"id": "video2",
|
||||
"ie_key": "Youtube",
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_archive_add.return_value = True
|
||||
|
||||
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
|
||||
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
|
||||
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/playlist")
|
||||
|
||||
success, message = task.mark()
|
||||
|
||||
assert success is True
|
||||
assert "marked as downloaded" in message
|
||||
mock_archive_add.assert_called_once()
|
||||
# Check that archive_add was called with the correct items
|
||||
call_args = mock_archive_add.call_args[0]
|
||||
archive_file = call_args[0]
|
||||
items = call_args[1]
|
||||
assert str(archive_file) == "/tmp/archive.txt"
|
||||
assert "youtube video1" in items
|
||||
assert "youtube video2" in items
|
||||
|
||||
def test_task_mark_no_url(self):
|
||||
"""Test task marking with no URL."""
|
||||
task = Task(id="test_id", name="test_task", url="")
|
||||
|
||||
success, message = task.mark()
|
||||
|
||||
assert success is False
|
||||
assert "No URL found" in message
|
||||
|
||||
def test_task_mark_no_archive_file(self):
|
||||
"""Test task marking with no archive file configured."""
|
||||
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
|
||||
mock_get_opts.return_value.get_all.return_value = {}
|
||||
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/video")
|
||||
|
||||
success, message = task.mark()
|
||||
|
||||
assert success is False
|
||||
assert "No archive file found" in message
|
||||
|
||||
@patch("app.library.Tasks.archive_delete")
|
||||
@patch("app.library.Tasks.extract_info")
|
||||
def test_task_unmark_success(self, mock_extract_info, mock_archive_delete):
|
||||
"""Test successful task unmarking."""
|
||||
# Mock extract_info to return playlist data
|
||||
mock_extract_info.return_value = {
|
||||
"_type": "playlist",
|
||||
"entries": [
|
||||
{
|
||||
"_type": "video",
|
||||
"id": "video1",
|
||||
"ie_key": "Youtube",
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_archive_delete.return_value = True
|
||||
|
||||
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
|
||||
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
|
||||
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/playlist")
|
||||
|
||||
success, message = task.unmark()
|
||||
|
||||
assert success is True
|
||||
assert "Removed" in message
|
||||
assert "items from archive file" in message
|
||||
mock_archive_delete.assert_called_once()
|
||||
|
||||
@patch("app.library.Tasks.extract_info")
|
||||
def test_task_mark_logic_invalid_extract_info(self, mock_extract_info):
|
||||
"""Test _mark_logic with invalid extract_info response."""
|
||||
mock_extract_info.return_value = None
|
||||
|
||||
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
|
||||
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
|
||||
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/video")
|
||||
|
||||
result = task._mark_logic()
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
success, message = result
|
||||
assert success is False
|
||||
assert "Failed to extract information" in message
|
||||
|
||||
@patch("app.library.Tasks.extract_info")
|
||||
def test_task_mark_logic_not_playlist(self, mock_extract_info):
|
||||
"""Test _mark_logic with non-playlist type."""
|
||||
mock_extract_info.return_value = {
|
||||
"_type": "video",
|
||||
"id": "video1",
|
||||
}
|
||||
|
||||
with patch.object(Task, "get_ytdlp_opts") as mock_get_opts:
|
||||
mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"}
|
||||
|
||||
task = Task(id="test_id", name="test_task", url="https://example.com/video")
|
||||
|
||||
result = task._mark_logic()
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
success, message = result
|
||||
assert success is False
|
||||
assert "Expected a playlist type" in message
|
||||
|
||||
|
||||
class TestTasks:
|
||||
"""Test the Tasks singleton manager."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test environment before each test."""
|
||||
# Reset singleton instance to ensure clean state
|
||||
Tasks._reset_singleton()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up after each test."""
|
||||
# Reset singleton instance to prevent test pollution
|
||||
Tasks._reset_singleton()
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_singleton(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test that Tasks follows singleton pattern."""
|
||||
mock_config.get_instance.return_value = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
instance1 = Tasks.get_instance()
|
||||
instance2 = Tasks.get_instance()
|
||||
|
||||
assert instance1 is instance2
|
||||
assert isinstance(instance1, Tasks)
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_initialization(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test Tasks initialization with proper dependencies."""
|
||||
mock_config_instance = Mock(
|
||||
debug=True, default_preset="test_preset", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus_instance = Mock()
|
||||
mock_eventbus.get_instance.return_value = mock_eventbus_instance
|
||||
mock_scheduler_instance = Mock()
|
||||
mock_scheduler.get_instance.return_value = mock_scheduler_instance
|
||||
mock_download_queue_instance = Mock()
|
||||
mock_download_queue.get_instance.return_value = mock_download_queue_instance
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tasks_file = Path(temp_dir) / "tasks.json"
|
||||
tasks = Tasks(file=tasks_file, config=mock_config_instance)
|
||||
|
||||
# Check initialization
|
||||
assert tasks._debug is True
|
||||
assert tasks._default_preset == "test_preset"
|
||||
assert tasks._file == tasks_file
|
||||
assert tasks._tasks == []
|
||||
assert tasks._scheduler == mock_scheduler_instance
|
||||
assert tasks._notify == mock_eventbus_instance
|
||||
assert tasks._downloadQueue == mock_download_queue_instance
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_get_all_empty(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test getting all tasks when no tasks exist."""
|
||||
mock_config.get_instance.return_value = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
tasks = Tasks.get_instance()
|
||||
|
||||
result = tasks.get_all()
|
||||
|
||||
assert result == []
|
||||
assert isinstance(result, list)
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_get_by_id_not_found(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test getting task by ID when task doesn't exist."""
|
||||
mock_config.get_instance.return_value = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
tasks = Tasks.get_instance()
|
||||
|
||||
result = tasks.get("nonexistent_id")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_load_empty_file(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test loading tasks from empty or non-existent file."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tasks_file = Path(temp_dir) / "nonexistent.json"
|
||||
tasks = Tasks(file=tasks_file, config=mock_config_instance)
|
||||
|
||||
result = tasks.load()
|
||||
|
||||
assert result == tasks # Should return self
|
||||
assert tasks.get_all() == []
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_load_valid_file(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test loading tasks from valid JSON file."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler_instance = Mock()
|
||||
mock_scheduler.get_instance.return_value = mock_scheduler_instance
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tasks_file = Path(temp_dir) / "tasks.json"
|
||||
|
||||
# Create valid JSON file with task data
|
||||
task_data = [
|
||||
{
|
||||
"id": "task1",
|
||||
"name": "Test Task 1",
|
||||
"url": "https://example.com/video1",
|
||||
"preset": "default",
|
||||
"timer": "0 6 * * *",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cli": "",
|
||||
"auto_start": True,
|
||||
"handler_enabled": True,
|
||||
}
|
||||
]
|
||||
tasks_file.write_text("[\n" + "\n".join(f" {line}" for line in str(task_data).split("\n")) + "\n]")
|
||||
|
||||
# Mock json.loads to return our test data
|
||||
with patch("json.loads", return_value=task_data):
|
||||
tasks = Tasks(file=tasks_file, config=mock_config_instance)
|
||||
|
||||
# Mock the scheduler.add method
|
||||
mock_scheduler_instance.add = Mock()
|
||||
|
||||
result = tasks.load()
|
||||
|
||||
assert result == tasks
|
||||
loaded_tasks = tasks.get_all()
|
||||
assert len(loaded_tasks) == 1
|
||||
assert loaded_tasks[0].id == "task1"
|
||||
assert loaded_tasks[0].name == "Test Task 1"
|
||||
assert loaded_tasks[0].url == "https://example.com/video1"
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_load_invalid_json(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test loading tasks from file with invalid JSON."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tasks_file = Path(temp_dir) / "tasks.json"
|
||||
tasks_file.write_text("invalid json {")
|
||||
|
||||
tasks = Tasks(file=tasks_file, config=mock_config_instance)
|
||||
|
||||
result = tasks.load()
|
||||
|
||||
assert result == tasks
|
||||
assert tasks.get_all() == [] # Should be empty due to invalid JSON
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_save_valid_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test saving valid tasks to file."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tasks_file = Path(temp_dir) / "tasks.json"
|
||||
tasks = Tasks(file=tasks_file, config=mock_config_instance)
|
||||
|
||||
task_data = [
|
||||
{
|
||||
"id": "task1",
|
||||
"name": "Test Task",
|
||||
"url": "https://example.com/video",
|
||||
"preset": "default",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cli": "",
|
||||
"timer": "",
|
||||
"auto_start": True,
|
||||
"handler_enabled": True,
|
||||
}
|
||||
]
|
||||
|
||||
result = tasks.save(task_data)
|
||||
|
||||
assert result == tasks
|
||||
assert tasks_file.exists()
|
||||
# Verify file content was written
|
||||
assert tasks_file.stat().st_size > 0
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_get_by_id_found(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test getting task by ID when task exists."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
|
||||
# Manually add a task to the internal list
|
||||
test_task = Task(id="test_id", name="Test Task", url="https://example.com/video")
|
||||
tasks._tasks.append(test_task)
|
||||
|
||||
result = tasks.get("test_id")
|
||||
|
||||
assert result is not None
|
||||
assert result.id == "test_id"
|
||||
assert result.name == "Test Task"
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_get_all_with_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test getting all tasks when tasks exist."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
|
||||
# Manually add tasks to the internal list
|
||||
task1 = Task(id="task1", name="Task 1", url="https://example.com/video1")
|
||||
task2 = Task(id="task2", name="Task 2", url="https://example.com/video2")
|
||||
tasks._tasks.extend([task1, task2])
|
||||
|
||||
result = tasks.get_all()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].id == "task1"
|
||||
assert result[1].id == "task2"
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_clear_with_scheduler_cleanup(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test clearing tasks with scheduler cleanup."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler_instance = Mock()
|
||||
mock_scheduler_instance.has.return_value = True
|
||||
mock_scheduler_instance.remove = Mock()
|
||||
mock_scheduler.get_instance.return_value = mock_scheduler_instance
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
|
||||
# Add a task with timer to the internal list
|
||||
task_with_timer = Task(id="task1", name="Task 1", url="https://example.com/video1", timer="0 6 * * *")
|
||||
tasks._tasks.append(task_with_timer)
|
||||
|
||||
result = tasks.clear()
|
||||
|
||||
assert result == tasks
|
||||
assert len(tasks.get_all()) == 0
|
||||
mock_scheduler_instance.has.assert_called_with("task1")
|
||||
mock_scheduler_instance.remove.assert_called_with("task1")
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_clear_no_tasks(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test clearing when no tasks exist."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler_instance = Mock()
|
||||
mock_scheduler.get_instance.return_value = mock_scheduler_instance
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
|
||||
result = tasks.clear()
|
||||
|
||||
assert result == tasks
|
||||
assert len(tasks.get_all()) == 0
|
||||
# Should not call scheduler methods when no tasks exist
|
||||
mock_scheduler_instance.has.assert_not_called()
|
||||
mock_scheduler_instance.remove.assert_not_called()
|
||||
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
def test_tasks_validate_valid_task_dict(self, mock_download_queue, mock_scheduler, mock_eventbus, mock_config):
|
||||
"""Test task validation with valid task dictionary."""
|
||||
mock_config.get_instance.return_value = Mock()
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue.get_instance.return_value = Mock()
|
||||
|
||||
valid_task = {
|
||||
"name": "Test Task",
|
||||
"url": "https://example.com/video",
|
||||
"timer": "0 6 * * *",
|
||||
"cli": "--write-info-json",
|
||||
}
|
||||
|
||||
with patch("app.library.Tasks.validate_url") as mock_validate_url:
|
||||
mock_validate_url.return_value = True
|
||||
|
||||
result = Tasks.validate(valid_task)
|
||||
|
||||
assert result is True
|
||||
mock_validate_url.assert_called_once_with("https://example.com/video", allow_internal=True)
|
||||
|
||||
def test_tasks_validate_invalid_task_type(self):
|
||||
"""Test task validation with invalid task type."""
|
||||
invalid_task = "not a dict or Task"
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid task type"):
|
||||
Tasks.validate(invalid_task)
|
||||
|
||||
def test_tasks_validate_missing_name(self):
|
||||
"""Test task validation with missing name."""
|
||||
invalid_task = {
|
||||
"url": "https://example.com/video",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="No name found"):
|
||||
Tasks.validate(invalid_task)
|
||||
|
||||
def test_tasks_validate_missing_url(self):
|
||||
"""Test task validation with missing URL."""
|
||||
invalid_task = {
|
||||
"name": "Test Task",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="No URL found"):
|
||||
Tasks.validate(invalid_task)
|
||||
|
||||
def test_tasks_validate_invalid_url(self):
|
||||
"""Test task validation with invalid URL."""
|
||||
invalid_task = {
|
||||
"name": "Test Task",
|
||||
"url": "invalid-url",
|
||||
}
|
||||
|
||||
with patch("app.library.Tasks.validate_url") as mock_validate_url:
|
||||
mock_validate_url.side_effect = ValueError("Invalid URL")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid URL format"):
|
||||
Tasks.validate(invalid_task)
|
||||
|
||||
def test_tasks_validate_invalid_timer(self):
|
||||
"""Test task validation with invalid timer format."""
|
||||
invalid_task = {
|
||||
"name": "Test Task",
|
||||
"url": "https://example.com/video",
|
||||
"timer": "invalid timer",
|
||||
}
|
||||
|
||||
with patch("app.library.Tasks.validate_url") as mock_validate_url:
|
||||
mock_validate_url.return_value = True
|
||||
|
||||
# Import error will be raised by cronsim for invalid format
|
||||
with patch("cronsim.CronSim") as mock_cronsim:
|
||||
mock_cronsim.side_effect = Exception("Invalid cron format")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid timer format"):
|
||||
Tasks.validate(invalid_task)
|
||||
|
||||
def test_tasks_validate_invalid_cli(self):
|
||||
"""Test task validation with invalid CLI options."""
|
||||
invalid_task = {
|
||||
"name": "Test Task",
|
||||
"url": "https://example.com/video",
|
||||
"cli": "invalid --cli options",
|
||||
}
|
||||
|
||||
with patch("app.library.Tasks.validate_url") as mock_validate_url:
|
||||
mock_validate_url.return_value = True
|
||||
|
||||
with patch("app.library.Utils.arg_converter") as mock_arg_converter:
|
||||
mock_arg_converter.side_effect = Exception("Invalid CLI args")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid command options for yt-dlp"):
|
||||
Tasks.validate(invalid_task)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
@patch("app.library.Tasks.Item")
|
||||
@patch("app.library.Tasks.datetime")
|
||||
@patch("app.library.Tasks.time")
|
||||
async def test_tasks_runner_success(
|
||||
self, mock_time, mock_datetime, mock_item, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
|
||||
):
|
||||
"""Test successful task runner execution."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus_instance = Mock()
|
||||
mock_eventbus.get_instance.return_value = mock_eventbus_instance
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
|
||||
# Mock download queue
|
||||
mock_download_queue_instance = Mock()
|
||||
mock_download_queue_instance.add = AsyncMock(return_value={"success": True, "id": "download123"})
|
||||
mock_download_queue.get_instance.return_value = mock_download_queue_instance
|
||||
|
||||
# Mock time and datetime
|
||||
mock_time.time.side_effect = [1000.0, 1005.5] # start and end times
|
||||
mock_datetime_instance = Mock()
|
||||
mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z"
|
||||
mock_datetime.now.return_value = mock_datetime_instance
|
||||
|
||||
# Mock Item.format
|
||||
mock_item.format.return_value = {"formatted": "item"}
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
|
||||
test_task = Task(
|
||||
id="task1",
|
||||
name="Test Task",
|
||||
url="https://example.com/video",
|
||||
preset="test_preset",
|
||||
folder="test_folder",
|
||||
template="test_template",
|
||||
cli="--write-info-json",
|
||||
auto_start=True,
|
||||
)
|
||||
|
||||
await tasks._runner(test_task)
|
||||
|
||||
# Verify download queue was called
|
||||
mock_download_queue_instance.add.assert_called_once()
|
||||
|
||||
# Verify Item.format was called with correct parameters
|
||||
mock_item.format.assert_called_once_with(
|
||||
{
|
||||
"url": "https://example.com/video",
|
||||
"preset": "test_preset",
|
||||
"folder": "test_folder",
|
||||
"template": "test_template",
|
||||
"cli": "--write-info-json",
|
||||
"auto_start": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Verify events were emitted
|
||||
assert mock_eventbus_instance.emit.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
@patch("app.library.Tasks.datetime")
|
||||
async def test_tasks_runner_no_url(
|
||||
self, mock_datetime, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
|
||||
):
|
||||
"""Test task runner with no URL."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus_instance = Mock()
|
||||
mock_eventbus.get_instance.return_value = mock_eventbus_instance
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
mock_download_queue_instance = Mock()
|
||||
mock_download_queue.get_instance.return_value = mock_download_queue_instance
|
||||
|
||||
mock_datetime_instance = Mock()
|
||||
mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z"
|
||||
mock_datetime.now.return_value = mock_datetime_instance
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
|
||||
test_task = Task(
|
||||
id="task1",
|
||||
name="Test Task",
|
||||
url="", # Empty URL
|
||||
)
|
||||
|
||||
await tasks._runner(test_task)
|
||||
|
||||
# Verify download queue was NOT called
|
||||
mock_download_queue_instance.add.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("app.library.Tasks.Config")
|
||||
@patch("app.library.Tasks.EventBus")
|
||||
@patch("app.library.Tasks.Scheduler")
|
||||
@patch("app.library.Tasks.DownloadQueue")
|
||||
@patch("app.library.Tasks.Item")
|
||||
@patch("app.library.Tasks.datetime")
|
||||
async def test_tasks_runner_download_queue_failure(
|
||||
self, mock_datetime, mock_item, mock_download_queue, mock_scheduler, mock_eventbus, mock_config
|
||||
):
|
||||
"""Test task runner when download queue add fails."""
|
||||
mock_config_instance = Mock(
|
||||
debug=False, default_preset="default", config_path="/tmp", tasks_handler_timer="15 */1 * * *"
|
||||
)
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus_instance = Mock()
|
||||
mock_eventbus.get_instance.return_value = mock_eventbus_instance
|
||||
mock_scheduler.get_instance.return_value = Mock()
|
||||
|
||||
# Mock download queue to raise exception
|
||||
mock_download_queue_instance = Mock()
|
||||
mock_download_queue_instance.add = AsyncMock(side_effect=Exception("Queue error"))
|
||||
mock_download_queue.get_instance.return_value = mock_download_queue_instance
|
||||
|
||||
mock_datetime_instance = Mock()
|
||||
mock_datetime_instance.isoformat.return_value = "2024-01-01T12:00:00Z"
|
||||
mock_datetime.now.return_value = mock_datetime_instance
|
||||
|
||||
mock_item.format.return_value = {"formatted": "item"}
|
||||
|
||||
tasks = Tasks(file=None, config=mock_config_instance)
|
||||
|
||||
test_task = Task(
|
||||
id="task1",
|
||||
name="Test Task",
|
||||
url="https://example.com/video",
|
||||
)
|
||||
|
||||
await tasks._runner(test_task)
|
||||
|
||||
# Verify error event was emitted (check last call)
|
||||
calls = mock_eventbus_instance.emit.call_args_list
|
||||
assert len(calls) > 0
|
||||
# Verify the last call was an error event
|
||||
last_call = calls[-1]
|
||||
assert "Task failed" in str(last_call)
|
||||
508
app/tests/test_ytdlpopts.py
Normal file
508
app/tests/test_ytdlpopts.py
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
"""Tests for YTDLPOpts class."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.Presets import Preset
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
|
||||
class TestYTDLPOpts:
|
||||
"""Test the YTDLPOpts class."""
|
||||
|
||||
def test_constructor_initializes_correctly(self):
|
||||
"""Test that YTDLPOpts constructor initializes all attributes."""
|
||||
with patch("app.library.YTDLPOpts.Config") as mock_config:
|
||||
mock_config_instance = Mock()
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
opts = YTDLPOpts()
|
||||
|
||||
assert opts._config is mock_config_instance
|
||||
assert opts._item_opts == {}
|
||||
assert opts._preset_opts == {}
|
||||
assert opts._item_cli == []
|
||||
assert opts._preset_cli == ""
|
||||
|
||||
def test_get_instance_returns_reset_instance(self):
|
||||
"""Test that get_instance returns a reset YTDLPOpts instance."""
|
||||
with patch("app.library.YTDLPOpts.Config"):
|
||||
opts = YTDLPOpts.get_instance()
|
||||
|
||||
assert isinstance(opts, YTDLPOpts)
|
||||
assert opts._item_opts == {}
|
||||
assert opts._preset_opts == {}
|
||||
assert opts._item_cli == []
|
||||
assert opts._preset_cli == ""
|
||||
|
||||
def test_add_cli_with_valid_args(self):
|
||||
"""Test adding valid CLI arguments."""
|
||||
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter:
|
||||
mock_converter.return_value = {"format": "best"}
|
||||
opts = YTDLPOpts()
|
||||
|
||||
result = opts.add_cli("--format best", from_user=False)
|
||||
|
||||
assert result is opts # Returns self for chaining
|
||||
assert "--format best" in opts._item_cli
|
||||
mock_converter.assert_called_once_with(args="--format best", level=False)
|
||||
|
||||
def test_add_cli_with_invalid_args_raises_error(self):
|
||||
"""Test that invalid CLI arguments raise ValueError."""
|
||||
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter:
|
||||
mock_converter.side_effect = Exception("Invalid argument")
|
||||
opts = YTDLPOpts()
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
|
||||
opts.add_cli("--invalid-arg", from_user=True)
|
||||
|
||||
def test_add_cli_with_empty_args_returns_self(self):
|
||||
"""Test that empty or invalid args return self without processing."""
|
||||
with patch("app.library.YTDLPOpts.Config"):
|
||||
opts = YTDLPOpts()
|
||||
|
||||
# Test empty string
|
||||
result1 = opts.add_cli("", from_user=False)
|
||||
assert result1 is opts
|
||||
assert len(opts._item_cli) == 0
|
||||
|
||||
# Test short string
|
||||
result2 = opts.add_cli("a", from_user=False)
|
||||
assert result2 is opts
|
||||
assert len(opts._item_cli) == 0
|
||||
|
||||
# Test non-string (this should be handled gracefully)
|
||||
result3 = opts.add_cli(123, from_user=False) # type: ignore
|
||||
assert result3 is opts
|
||||
assert len(opts._item_cli) == 0
|
||||
|
||||
def test_add_with_valid_config(self):
|
||||
"""Test adding configuration options."""
|
||||
with patch("app.library.YTDLPOpts.Config"):
|
||||
opts = YTDLPOpts()
|
||||
|
||||
config = {"format": "best", "quality": "720p"}
|
||||
result = opts.add(config, from_user=False)
|
||||
|
||||
assert result is opts
|
||||
assert opts._item_opts["format"] == "best"
|
||||
assert opts._item_opts["quality"] == "720p"
|
||||
|
||||
def test_add_with_user_config_filters_bad_options(self):
|
||||
"""Test that user config filters out dangerous options."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config"),
|
||||
patch("app.library.YTDLPOpts.REMOVE_KEYS", [{"paths": "-P, --paths", "outtmpl": "-o, --output"}]),
|
||||
):
|
||||
opts = YTDLPOpts()
|
||||
|
||||
config = {
|
||||
"format": "best",
|
||||
"paths": "/dangerous/path", # Should be filtered
|
||||
"outtmpl": "dangerous_template", # Should be filtered
|
||||
"quality": "720p",
|
||||
}
|
||||
|
||||
result = opts.add(config, from_user=True)
|
||||
|
||||
assert result is opts
|
||||
assert opts._item_opts["format"] == "best"
|
||||
assert opts._item_opts["quality"] == "720p"
|
||||
assert "paths" not in opts._item_opts
|
||||
assert "outtmpl" not in opts._item_opts
|
||||
|
||||
def test_preset_with_valid_preset(self):
|
||||
"""Test applying a valid preset."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.Presets") as mock_presets,
|
||||
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
|
||||
):
|
||||
# Mock config
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.config_path = "/test/config"
|
||||
mock_config_instance.download_path = "/test/downloads"
|
||||
mock_config_instance.temp_path = "/test/temp"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
# Mock preset
|
||||
mock_preset = Mock(spec=Preset)
|
||||
mock_preset.id = "test_preset"
|
||||
mock_preset.name = "Test Preset"
|
||||
mock_preset.cli = "--format best"
|
||||
mock_preset.cookies = None
|
||||
mock_preset.template = "custom_template"
|
||||
mock_preset.folder = "custom_folder"
|
||||
|
||||
mock_presets_instance = Mock()
|
||||
mock_presets_instance.get.return_value = mock_preset
|
||||
mock_presets.get_instance.return_value = mock_presets_instance
|
||||
|
||||
mock_converter.return_value = {"format": "best"}
|
||||
|
||||
with patch("app.library.YTDLPOpts.calc_download_path") as mock_calc_path:
|
||||
mock_calc_path.return_value = "/test/downloads/custom_folder"
|
||||
|
||||
opts = YTDLPOpts()
|
||||
result = opts.preset("test_preset")
|
||||
|
||||
assert result is opts
|
||||
assert opts._preset_cli == "--format best"
|
||||
assert opts._preset_opts["outtmpl"]["default"] == "custom_template"
|
||||
assert opts._preset_opts["paths"]["home"] == "/test/downloads/custom_folder"
|
||||
|
||||
def test_preset_with_nonexistent_preset(self):
|
||||
"""Test applying a nonexistent preset returns self."""
|
||||
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.Presets") as mock_presets:
|
||||
mock_presets_instance = Mock()
|
||||
mock_presets_instance.get.return_value = None
|
||||
mock_presets.get_instance.return_value = mock_presets_instance
|
||||
|
||||
opts = YTDLPOpts()
|
||||
result = opts.preset("nonexistent")
|
||||
|
||||
assert result is opts
|
||||
assert opts._preset_cli == ""
|
||||
assert opts._preset_opts == {}
|
||||
|
||||
def test_preset_with_cookies_creates_file(self):
|
||||
"""Test that preset with cookies creates cookie file."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.Presets") as mock_presets,
|
||||
patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies,
|
||||
):
|
||||
# Mock config
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.config_path = "/test/config"
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
# Mock preset with cookies
|
||||
mock_preset = Mock(spec=Preset)
|
||||
mock_preset.id = "cookie_preset"
|
||||
mock_preset.name = "Cookie Preset"
|
||||
mock_preset.cli = None
|
||||
mock_preset.cookies = "cookie_data"
|
||||
mock_preset.template = None
|
||||
mock_preset.folder = None
|
||||
|
||||
mock_presets_instance = Mock()
|
||||
mock_presets_instance.get.return_value = mock_preset
|
||||
mock_presets.get_instance.return_value = mock_presets_instance
|
||||
|
||||
# Use real Path but mock file operations
|
||||
with (
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
patch("pathlib.Path.write_text"),
|
||||
):
|
||||
opts = YTDLPOpts()
|
||||
opts.preset("cookie_preset")
|
||||
|
||||
# Check that the cookie file would be created at the right path
|
||||
expected_path = "/test/config/cookies/cookie_preset.txt"
|
||||
assert opts._preset_opts["cookiefile"] == expected_path
|
||||
mock_load_cookies.assert_called_once()
|
||||
|
||||
def test_preset_with_invalid_cli_raises_error(self):
|
||||
"""Test that preset with invalid CLI raises ValueError."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config"),
|
||||
patch("app.library.YTDLPOpts.Presets") as mock_presets,
|
||||
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
|
||||
):
|
||||
mock_preset = Mock(spec=Preset)
|
||||
mock_preset.id = "bad_preset"
|
||||
mock_preset.name = "Bad Preset"
|
||||
mock_preset.cli = "--invalid-option"
|
||||
mock_preset.cookies = None
|
||||
mock_preset.template = None
|
||||
mock_preset.folder = None
|
||||
|
||||
mock_presets_instance = Mock()
|
||||
mock_presets_instance.get.return_value = mock_preset
|
||||
mock_presets.get_instance.return_value = mock_presets_instance
|
||||
|
||||
mock_converter.side_effect = Exception("Invalid CLI")
|
||||
|
||||
opts = YTDLPOpts()
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid preset 'Bad Preset' command options for yt-dlp"):
|
||||
opts.preset("bad_preset")
|
||||
|
||||
def test_get_all_with_default_options(self):
|
||||
"""Test get_all returns correct default options."""
|
||||
with patch("app.library.YTDLPOpts.Config") as mock_config:
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.temp_path = "/temp"
|
||||
mock_config_instance.output_template = "default_template"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config_instance.debug = False
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
with patch("app.library.YTDLPOpts.merge_dict") as mock_merge:
|
||||
mock_merge.return_value = {
|
||||
"paths": {"home": "/downloads", "temp": "/temp"},
|
||||
"outtmpl": {"default": "default_template", "chapter": "chapter_template"},
|
||||
}
|
||||
|
||||
opts = YTDLPOpts()
|
||||
result = opts.get_all(keep=True)
|
||||
|
||||
assert result["paths"]["home"] == "/downloads"
|
||||
assert result["outtmpl"]["default"] == "default_template"
|
||||
|
||||
def test_get_all_processes_cli_arguments(self):
|
||||
"""Test get_all processes CLI arguments correctly."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
|
||||
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
|
||||
):
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.temp_path = "/temp"
|
||||
mock_config_instance.output_template = "default_template"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config_instance.debug = False
|
||||
mock_config_instance.get_replacers.return_value = {"home": "/test"}
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
mock_converter.return_value = {"format": "best"}
|
||||
mock_merge.return_value = {"format": "best"}
|
||||
|
||||
opts = YTDLPOpts()
|
||||
opts._item_cli = ["--format best"]
|
||||
opts._preset_cli = "--quality 720p"
|
||||
|
||||
opts.get_all(keep=True)
|
||||
|
||||
# Should join CLI args and process replacers
|
||||
expected_cli = "--quality 720p\n--format best"
|
||||
mock_converter.assert_called_once_with(args=expected_cli, level=True)
|
||||
|
||||
def test_get_all_handles_format_special_cases(self):
|
||||
"""Test get_all handles special format values correctly."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
|
||||
):
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.temp_path = "/temp"
|
||||
mock_config_instance.output_template = "default_template"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config_instance.debug = False
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
opts = YTDLPOpts()
|
||||
|
||||
# Test "not_set" format
|
||||
mock_merge.return_value = {"format": "not_set"}
|
||||
result = opts.get_all(keep=True)
|
||||
assert result["format"] is None
|
||||
|
||||
# Test "default" format
|
||||
mock_merge.return_value = {"format": "default"}
|
||||
result = opts.get_all(keep=True)
|
||||
assert result["format"] is None
|
||||
|
||||
# Test "best" format
|
||||
mock_merge.return_value = {"format": "best"}
|
||||
result = opts.get_all(keep=True)
|
||||
assert result["format"] is None
|
||||
|
||||
# Test "-best" format (should remove leading dash)
|
||||
mock_merge.return_value = {"format": "-best"}
|
||||
result = opts.get_all(keep=True)
|
||||
assert result["format"] == "best"
|
||||
|
||||
def test_get_all_with_invalid_cli_raises_error(self):
|
||||
"""Test get_all raises error for invalid CLI arguments."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
|
||||
):
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.temp_path = "/temp"
|
||||
mock_config_instance.output_template = "default_template"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config_instance.debug = False
|
||||
mock_config_instance.get_replacers.return_value = {}
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
mock_converter.side_effect = Exception("Invalid CLI")
|
||||
|
||||
opts = YTDLPOpts()
|
||||
opts._item_cli = ["--invalid-arg"]
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid command options for yt-dlp were given"):
|
||||
opts.get_all()
|
||||
|
||||
def test_get_all_resets_unless_keep_true(self):
|
||||
"""Test get_all resets instance unless keep=True."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
|
||||
):
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.temp_path = "/temp"
|
||||
mock_config_instance.output_template = "default_template"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config_instance.debug = False
|
||||
mock_config_instance.get_replacers.return_value = {} # Return empty dict
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
mock_merge.return_value = {}
|
||||
|
||||
opts = YTDLPOpts()
|
||||
opts._item_opts = {"test": "value"}
|
||||
opts._item_cli = ["--test"]
|
||||
|
||||
# Test with keep=False (default)
|
||||
opts.get_all(keep=False)
|
||||
assert opts._item_opts == {}
|
||||
assert opts._item_cli == []
|
||||
|
||||
# Reset test data
|
||||
opts._item_opts = {"test": "value"}
|
||||
opts._item_cli = ["--test"]
|
||||
|
||||
# Test with keep=True
|
||||
opts.get_all(keep=True)
|
||||
assert opts._item_opts == {"test": "value"}
|
||||
assert opts._item_cli == ["--test"]
|
||||
|
||||
def test_reset_clears_all_options(self):
|
||||
"""Test reset clears all internal state."""
|
||||
with patch("app.library.YTDLPOpts.Config"):
|
||||
opts = YTDLPOpts()
|
||||
|
||||
# Set some state
|
||||
opts._item_opts = {"format": "best"}
|
||||
opts._preset_opts = {"quality": "720p"}
|
||||
opts._item_cli = ["--format best"]
|
||||
opts._preset_cli = "--quality 720p"
|
||||
|
||||
result = opts.reset()
|
||||
|
||||
assert result is opts
|
||||
assert opts._item_opts == {}
|
||||
assert opts._preset_opts == {}
|
||||
assert opts._item_cli == []
|
||||
assert opts._preset_cli == ""
|
||||
|
||||
def test_method_chaining(self):
|
||||
"""Test that methods support chaining."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config"),
|
||||
patch("app.library.YTDLPOpts.arg_converter"),
|
||||
patch("app.library.YTDLPOpts.Presets") as mock_presets,
|
||||
):
|
||||
mock_presets_instance = Mock()
|
||||
mock_presets_instance.get.return_value = None
|
||||
mock_presets.get_instance.return_value = mock_presets_instance
|
||||
|
||||
opts = YTDLPOpts()
|
||||
|
||||
# Test chaining
|
||||
result = opts.add_cli("--format best").add({"quality": "720p"}).preset("nonexistent").reset()
|
||||
|
||||
assert result is opts
|
||||
|
||||
def test_debug_logging(self):
|
||||
"""Test debug logging when enabled."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
|
||||
patch("app.library.YTDLPOpts.LOG") as mock_log,
|
||||
):
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.temp_path = "/temp"
|
||||
mock_config_instance.output_template = "default_template"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config_instance.debug = True # Enable debug
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
test_data = {"format": "best"}
|
||||
mock_merge.return_value = test_data
|
||||
|
||||
opts = YTDLPOpts()
|
||||
opts.get_all(keep=True)
|
||||
|
||||
mock_log.debug.assert_called_once_with(f"Final yt-dlp options: '{test_data!s}'.")
|
||||
|
||||
def test_cookie_loading_error_handling(self):
|
||||
"""Test error handling when cookie loading fails."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.Presets") as mock_presets,
|
||||
patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies,
|
||||
patch("app.library.YTDLPOpts.LOG") as mock_log,
|
||||
):
|
||||
# Mock config
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.config_path = "/test/config"
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
# Mock preset with cookies
|
||||
mock_preset = Mock(spec=Preset)
|
||||
mock_preset.id = "cookie_preset"
|
||||
mock_preset.name = "Cookie Preset"
|
||||
mock_preset.cli = None
|
||||
mock_preset.cookies = "invalid_cookie_data"
|
||||
mock_preset.template = None
|
||||
mock_preset.folder = None
|
||||
|
||||
mock_presets_instance = Mock()
|
||||
mock_presets_instance.get.return_value = mock_preset
|
||||
mock_presets.get_instance.return_value = mock_presets_instance
|
||||
|
||||
# Mock cookie loading failure
|
||||
mock_load_cookies.side_effect = ValueError("Invalid cookies")
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text"):
|
||||
opts = YTDLPOpts()
|
||||
opts.preset("cookie_preset")
|
||||
|
||||
# Should log error but not raise
|
||||
mock_log.error.assert_called_once()
|
||||
error_args = mock_log.error.call_args[0][0]
|
||||
assert "Failed to load 'Cookie Preset' cookies" in error_args
|
||||
|
||||
# cookiefile should not be set
|
||||
assert "cookiefile" not in opts._preset_opts
|
||||
|
||||
def test_replacer_substitution_in_cli(self):
|
||||
"""Test that CLI arguments get replacer substitution."""
|
||||
with (
|
||||
patch("app.library.YTDLPOpts.Config") as mock_config,
|
||||
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
|
||||
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
|
||||
):
|
||||
mock_config_instance = Mock()
|
||||
mock_config_instance.download_path = "/downloads"
|
||||
mock_config_instance.temp_path = "/temp"
|
||||
mock_config_instance.output_template = "default_template"
|
||||
mock_config_instance.output_template_chapter = "chapter_template"
|
||||
mock_config_instance.debug = False
|
||||
mock_config_instance.get_replacers.return_value = {"home": "/actual/home", "user": "testuser"}
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
|
||||
mock_converter.return_value = {"output": "/actual/home/testuser"}
|
||||
mock_merge.return_value = {}
|
||||
|
||||
opts = YTDLPOpts()
|
||||
opts._item_cli = ["--output %(home)s/%(user)s"]
|
||||
|
||||
opts.get_all(keep=True)
|
||||
|
||||
# Should replace %(home)s and %(user)s
|
||||
expected_cli = "--output /actual/home/testuser"
|
||||
mock_converter.assert_called_once_with(args=expected_cli, level=True)
|
||||
Loading…
Reference in a new issue