ytptube/app/tests/test_download_utils.py

353 lines
14 KiB
Python

import asyncio
import logging
import os
import time
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from app.library.downloads.utils import (
LIMITS,
create_debug_safe_dict,
get_extractor_limit,
handle_task_exception,
is_download_stale,
is_safe_to_delete_dir,
parse_extractor_limit,
safe_relative_path,
wait_for_process_with_timeout,
)
class TestPathUtilities:
def test_safe_relative_path_success(self) -> None:
base = Path("/downloads")
file = Path("/downloads/video.mp4")
result = safe_relative_path(file, base)
assert "video.mp4" == result, "Should return relative path"
def test_safe_relative_path_nested(self) -> None:
base = Path("/downloads")
file = Path("/downloads/folder/subfolder/video.mp4")
result = safe_relative_path(file, base)
assert "folder/subfolder/video.mp4" == result, "Should return nested relative path"
def test_safe_relative_path_with_fallback(self) -> None:
base = Path("/wrong/path")
fallback = Path("/temp")
file = Path("/temp/video.mp4")
result = safe_relative_path(file, base, fallback)
assert "video.mp4" == result, "Should use fallback path"
def test_safe_rel_no_fallback(self) -> None:
base = Path("/wrong/path")
file = Path("/downloads/video.mp4")
result = safe_relative_path(file, base)
assert "/downloads/video.mp4" == result, "Should return absolute path when both fail"
def test_safe_relative_path_fallback_also_fails(self) -> None:
base = Path("/wrong/path")
fallback = Path("/also/wrong")
file = Path("/downloads/video.mp4")
result = safe_relative_path(file, base, fallback)
assert "/downloads/video.mp4" == result, "Should return absolute when both fail"
def test_is_safe_to_delete_dir_root_protection(self) -> None:
path = Path("/tmp/downloads")
root = Path("/tmp/downloads")
result = is_safe_to_delete_dir(path, root)
assert result is False, "Should refuse to delete root directory"
def test_is_safe_to_delete_dir_safe_path(self) -> None:
path = Path("/tmp/downloads/subfolder")
root = Path("/tmp/downloads")
result = is_safe_to_delete_dir(path, root)
assert result is True, "Should allow deleting subdirectory"
def test_is_safe_to_delete_dir_string_comparison(self) -> None:
path = Path("/tmp/downloads")
root = "/tmp/downloads"
result = is_safe_to_delete_dir(path, root)
assert result is False, "Should handle string root path"
class TestProcessUtilities:
def test_wait_timeout_done(self) -> None:
proc = Mock()
proc.is_alive = Mock(return_value=False)
result = wait_for_process_with_timeout(proc, timeout=1.0)
assert result is True, "Should return True when process terminates immediately"
def test_wait_timeout_delay(self) -> None:
proc = Mock()
call_count = [0]
def is_alive_side_effect():
call_count[0] += 1
return call_count[0] < 3
proc.is_alive = Mock(side_effect=is_alive_side_effect)
with patch("time.sleep"):
result = wait_for_process_with_timeout(proc, timeout=1.0, check_interval=0.1)
assert result is True, "Should return True when process terminates during wait"
assert proc.is_alive.call_count >= 3, "Should check process multiple times"
def test_wait_for_process_with_timeout_expires(self) -> None:
proc = Mock()
proc.is_alive = Mock(return_value=True)
with patch("time.time") as mock_time, patch("time.sleep"):
mock_time.side_effect = [0, 0.5, 1.0, 1.5]
result = wait_for_process_with_timeout(proc, timeout=1.0, check_interval=0.1)
assert result is False, "Should return False when timeout reached"
def test_wait_for_process_with_custom_interval(self) -> None:
proc = Mock()
proc.is_alive = Mock(return_value=False)
result = wait_for_process_with_timeout(proc, timeout=5.0, check_interval=0.5)
assert result is True, "Should respect custom check interval"
class TestConfigUtilities:
def test_parse_extractor_limit_valid_env(self) -> None:
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "3"}):
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
assert 3 == result, "Should use environment variable value"
def test_parse_extractor_limit_env_exceeds_max(self) -> None:
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "15"}):
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
assert 10 == result, "Should cap at max_workers"
def test_parse_limit_nondigit(self) -> None:
logger = logging.getLogger("test")
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "abc"}):
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger)
assert 5 == result, "Should use default when env var is invalid"
def test_parse_extractor_limit_invalid_env_zero(self) -> None:
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "0"}):
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
assert 5 == result, "Should use default when env var is zero"
def test_parse_extractor_limit_no_env(self) -> None:
result = parse_extractor_limit("youtube", default_limit=5, max_workers=10)
assert 5 == result, "Should use default when no env var set"
def test_parse_limit_default_max(self) -> None:
result = parse_extractor_limit("youtube", default_limit=15, max_workers=10)
assert 10 == result, "Should cap default at max_workers"
def test_parse_limit_warns(self) -> None:
logger = Mock()
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_YOUTUBE": "invalid"}):
parse_extractor_limit("youtube", default_limit=5, max_workers=10, logger=logger)
logger.warning.assert_called_once()
assert "Invalid extractor limit" in logger.warning.call_args[0][0], "Should log warning"
def test_get_extractor_limit_creates_new(self) -> None:
LIMITS.clear()
logger = logging.getLogger("test")
semaphore = get_extractor_limit("youtube", max_workers=10, max_workers_per_extractor=5, logger=logger)
assert isinstance(semaphore, asyncio.Semaphore), "Should return semaphore"
assert "youtube" in LIMITS, "Should store in LIMITS dict"
def test_get_extractor_limit_reuses_existing(self) -> None:
LIMITS.clear()
logger = logging.getLogger("test")
sem1 = get_extractor_limit("youtube", max_workers=10, max_workers_per_extractor=5, logger=logger)
sem2 = get_extractor_limit("youtube", max_workers=10, max_workers_per_extractor=5, logger=logger)
assert sem1 is sem2, "Should reuse existing semaphore"
def test_get_extractor_limit_respects_env_var(self) -> None:
LIMITS.clear()
logger = logging.getLogger("test")
with patch.dict(os.environ, {"YTP_MAX_WORKERS_FOR_TWITCH": "2"}):
semaphore = get_extractor_limit("twitch", max_workers=10, max_workers_per_extractor=5, logger=logger)
assert semaphore._value == 2, "Should respect environment variable"
class TestDataUtilities:
def test_create_debug_safe_dict_filters_formats(self) -> None:
data = {
"status": "downloading",
"filename": "video.mp4",
"info_dict": {"id": "123", "title": "Video", "formats": [{"format_id": "1"}], "description": "Long text"},
}
result = create_debug_safe_dict(data)
assert "downloading" == result["status"], "Should include status"
assert "video.mp4" == result["filename"], "Should include filename"
assert "formats" not in result["info_dict"], "Should exclude formats"
assert "description" not in result["info_dict"], "Should exclude description"
assert "123" == result["info_dict"]["id"], "Should include id"
def test_create_debug_safe_dict_custom_exclude(self) -> None:
data = {
"status": "downloading",
"filename": "video.mp4",
"info_dict": {"id": "123", "title": "Video", "custom_field": "value"},
}
result = create_debug_safe_dict(data, exclude_keys=["custom_field"])
assert "custom_field" not in result["info_dict"], "Should exclude custom field"
assert "123" == result["info_dict"]["id"], "Should include id"
def test_debug_safe_filters_none(self) -> None:
data = {
"status": "downloading",
"info_dict": {"id": "123", "none_value": None, "lambda_value": lambda: None, "title": "Video"},
}
result = create_debug_safe_dict(data)
assert "none_value" not in result["info_dict"], "Should filter None values"
assert "lambda_value" not in result["info_dict"], "Should filter lambda functions"
assert "Video" == result["info_dict"]["title"], "Should include regular values"
def test_create_debug_safe_dict_empty_info_dict(self) -> None:
data = {"status": "downloading", "filename": "video.mp4"}
result = create_debug_safe_dict(data)
assert {} == result["info_dict"], "Should handle missing info_dict"
class TestStateUtilities:
def test_stale_finished(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 500, current_status="finished", is_running=False, auto_start=True
)
assert result is False, "Finished downloads are never stale"
def test_is_download_stale_terminal_status_error(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 500, current_status="error", is_running=False, auto_start=True
)
assert result is False, "Error downloads are never stale"
def test_stale_cancelled(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 500, current_status="cancelled", is_running=False, auto_start=True
)
assert result is False, "Cancelled downloads are never stale"
def test_stale_downloading(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 500, current_status="downloading", is_running=False, auto_start=True
)
assert result is False, "Downloading status is never stale"
def test_stale_postprocessing(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 500, current_status="postprocessing", is_running=False, auto_start=True
)
assert result is False, "Postprocessing status is never stale"
def test_is_download_stale_not_auto_start(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 500, current_status="pending", is_running=False, auto_start=False
)
assert result is False, "Non-auto-start downloads are never stale"
def test_is_download_stale_still_running(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 500, current_status="pending", is_running=True, auto_start=True
)
assert result is False, "Running downloads are never stale"
def test_is_download_stale_not_enough_time(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 100,
current_status="pending",
is_running=False,
auto_start=True,
min_elapsed=300,
)
assert result is False, "Should not be stale before timeout"
def test_is_download_stale_timeout_reached(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 400,
current_status="pending",
is_running=False,
auto_start=True,
min_elapsed=300,
)
assert result is True, "Should be stale after timeout"
def test_is_download_stale_custom_timeout(self) -> None:
result = is_download_stale(
started_time=int(time.time()) - 150,
current_status="pending",
is_running=False,
auto_start=True,
min_elapsed=100,
)
assert result is True, "Should respect custom timeout"
class TestTaskExceptionHandling:
@pytest.mark.asyncio
async def test_handle_task_exception_ignores_cancelled(self) -> None:
logger = Mock()
async def cancelled_task():
raise asyncio.CancelledError()
task = asyncio.create_task(cancelled_task())
try:
await task
except asyncio.CancelledError:
pass
handle_task_exception(task, logger)
logger.error.assert_not_called()
@pytest.mark.asyncio
async def test_task_exception_success(self) -> None:
logger = Mock()
async def successful_task():
return "success"
task = asyncio.create_task(successful_task())
await task
handle_task_exception(task, logger)
logger.error.assert_not_called()
@pytest.mark.asyncio
async def test_handle_task_exception_logs_exception(self) -> None:
logger = Mock()
async def failing_task():
raise ValueError("Test error")
task = asyncio.create_task(failing_task(), name="test_task")
try:
await task
except ValueError:
pass
handle_task_exception(task, logger)
logger.error.assert_called_once()
error_msg = logger.error.call_args[0][0] % logger.error.call_args[0][1:]
assert "test_task" in error_msg, "Should include task name"
assert "Test error" in error_msg, "Should include exception message"
@pytest.mark.asyncio
async def test_handle_task_exception_unknown_task_name(self) -> None:
logger = Mock()
async def failing_task():
raise RuntimeError("Unknown task error")
task = asyncio.create_task(failing_task())
try:
await task
except RuntimeError:
pass
handle_task_exception(task, logger)
logger.error.assert_called_once()
error_msg = logger.error.call_args[0][0] % logger.error.call_args[0][1:]
assert "unknown_task" in error_msg or "Task" in error_msg, "Should handle unknown task name"