ytptube/app/tests/test_update_checker.py
2026-01-18 19:18:00 +03:00

362 lines
14 KiB
Python

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestUpdateChecker:
"""Test UpdateChecker functionality."""
def setup_method(self):
"""Set up test fixtures."""
from app.library.cache import Cache
from app.library.config import Config
from app.library.Events import EventBus
from app.library.Scheduler import Scheduler
from app.library.UpdateChecker import UpdateChecker
Config._reset_singleton()
Scheduler._reset_singleton()
UpdateChecker._reset_singleton()
EventBus._reset_singleton()
Cache._reset_singleton()
def test_singleton_pattern(self):
"""Test that UpdateChecker follows singleton pattern."""
from app.library.UpdateChecker import UpdateChecker
instance1 = UpdateChecker.get_instance()
instance2 = UpdateChecker.get_instance()
assert instance1 is instance2, "Should return same instance"
def test_initialization_with_defaults(self):
"""Test UpdateChecker initializes with default config and scheduler."""
from app.library.UpdateChecker import UpdateChecker
checker = UpdateChecker.get_instance()
assert checker._config is not None, "Should have config instance"
assert checker._scheduler is not None, "Should have scheduler instance"
assert checker._notify is not None, "Should have EventBus instance"
assert checker._job_id is None, "Should have no job ID initially"
def test_attach_schedules_check_when_enabled(self):
"""Test that attach schedules update check when config.check_for_updates is True."""
import asyncio
from app.library.config import Config
from app.library.Events import EventBus
from app.library.Scheduler import Scheduler
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
# Create scheduler with a fresh event loop
loop = asyncio.new_event_loop()
scheduler = Scheduler.get_instance(loop=loop)
notify = EventBus.get_instance()
# Mock APP_VERSION to be a release version
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config, scheduler=scheduler)
app_mock = MagicMock()
try:
checker.attach(app_mock)
assert checker._job_id is not None, "Should have scheduled a job"
assert "update_checker" == checker._job_id, "Job ID should be 'update_checker'"
finally:
# Clean up
if checker._job_id:
scheduler.remove(checker._job_id)
loop.close()
def test_attach_skips_scheduling_when_disabled(self):
"""Test that attach skips scheduling when config.check_for_updates is False."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = False
checker = UpdateChecker.get_instance(config=config)
app_mock = MagicMock()
checker.attach(app_mock)
assert checker._job_id is None, "Should not have scheduled a job when disabled"
def test_attach_skips_scheduling_for_dev_version(self):
"""Test that attach skips scheduling when running dev version."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
with patch("app.library.UpdateChecker.APP_VERSION", "dev-master"):
checker = UpdateChecker.get_instance(config=config)
app_mock = MagicMock()
checker.attach(app_mock)
assert checker._job_id is None, "Should not schedule job for dev version"
@pytest.mark.asyncio
async def test_on_shutdown_removes_scheduled_job(self):
"""Test that on_shutdown removes the scheduled job."""
import asyncio
from app.library.config import Config
from app.library.Events import EventBus
from app.library.Scheduler import Scheduler
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
# Create scheduler with a fresh event loop
loop = asyncio.new_event_loop()
scheduler = Scheduler.get_instance(loop=loop)
notify = EventBus.get_instance()
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config, scheduler=scheduler)
app_mock = MagicMock()
try:
checker.attach(app_mock)
initial_job_id = checker._job_id
assert initial_job_id is not None, "Should have job ID before shutdown"
await checker.on_shutdown(app_mock)
assert checker._job_id is None, "Should clear job ID after shutdown"
finally:
loop.close()
@pytest.mark.asyncio
async def test_check_for_updates_skips_when_disabled(self):
"""Test that check_for_updates skips when config.check_for_updates is False."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = False
config.new_version = ""
checker = UpdateChecker.get_instance(config=config)
# Should return disabled status
status, new_version = await checker.check_for_updates()
assert "disabled" == status, "Should return disabled status"
assert new_version is None, "Should return None for new_version when disabled"
assert "" == config.new_version, "Should not update new_version when disabled"
@pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client")
async def test_check_for_updates_finds_newer_version(self, mock_client):
"""Test that check_for_updates detects when a newer version is available."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
config.new_version = ""
# Mock HTTP response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"tag_name": "v99.0.0"}
mock_context = AsyncMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
mock_client.return_value = mock_context
# Mock current version to be older
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config)
status, new_version = await checker.check_for_updates()
assert "update_available" == status, "Should return update_available status"
assert "v99.0.0" == new_version, "Should return new version tag"
assert "v99.0.0" == config.new_version, "Should store new version tag"
assert checker._job_id is None, "Should stop scheduled task after finding update"
@pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client")
async def test_check_for_updates_no_update_available(self, mock_client):
"""Test that check_for_updates correctly handles when no update is available."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
config.new_version = ""
# Mock HTTP response with older version (current is hardcoded as 1.0.14 in the code)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"tag_name": "v1.0.0"}
mock_context = AsyncMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
mock_client.return_value = mock_context
checker = UpdateChecker.get_instance(config=config)
checker._job_id = "test-job" # Set a job ID to verify it's not removed
status, new_version = await checker.check_for_updates()
assert "up_to_date" == status, "Should return up_to_date status"
assert new_version is None, "Should return None for new_version"
assert "" == config.new_version, "Should clear new_version when no update available"
assert "test-job" == checker._job_id, "Should keep scheduled task running"
@pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client")
async def test_check_for_updates_handles_http_error(self, mock_client):
"""Test that check_for_updates handles HTTP errors gracefully."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
config.new_version = ""
# Mock HTTP error
mock_response = MagicMock()
mock_response.status_code = 404
mock_context = AsyncMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
mock_client.return_value = mock_context
checker = UpdateChecker.get_instance(config=config)
status, new_version = await checker.check_for_updates()
assert "error" == status, "Should return error status"
assert new_version is None, "Should return None for new_version on error"
assert "" == config.new_version, "Should not set new_version on HTTP error"
@pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client")
async def test_check_for_updates_handles_exception(self, mock_client):
"""Test that check_for_updates handles exceptions gracefully."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
config.new_version = ""
# Mock exception during HTTP request
mock_context = AsyncMock()
mock_context.__aenter__.return_value.get = AsyncMock(side_effect=Exception("Network error"))
mock_client.return_value = mock_context
checker = UpdateChecker.get_instance(config=config)
status, new_version = await checker.check_for_updates()
assert "error" == status, "Should return error status on exception"
assert new_version is None, "Should return None for new_version on exception"
assert "" == config.new_version, "Should not set new_version on exception"
def test_compare_versions_newer_available(self):
"""Test version comparison detects newer version."""
from app.library.UpdateChecker import UpdateChecker
checker = UpdateChecker.get_instance()
assert checker._compare_versions("1.0.0", "2.0.0") is True, "Should detect 2.0.0 > 1.0.0"
assert checker._compare_versions("1.0.0", "1.1.0") is True, "Should detect 1.1.0 > 1.0.0"
assert checker._compare_versions("1.0.0", "1.0.1") is True, "Should detect 1.0.1 > 1.0.0"
def test_compare_versions_same_version(self):
"""Test version comparison with same version."""
from app.library.UpdateChecker import UpdateChecker
checker = UpdateChecker.get_instance()
assert checker._compare_versions("1.0.0", "1.0.0") is False, "Should detect versions are equal"
def test_compare_versions_older_version(self):
"""Test version comparison with older version."""
from app.library.UpdateChecker import UpdateChecker
checker = UpdateChecker.get_instance()
assert checker._compare_versions("2.0.0", "1.0.0") is False, "Should detect 1.0.0 is not newer than 2.0.0"
def test_github_api_url_constant(self):
"""Test that GitHub API URL is correctly defined."""
from app.library.UpdateChecker import UpdateChecker
expected_url = "https://api.github.com/repos/arabcoders/ytptube/releases/latest"
assert UpdateChecker.GITHUB_API_URL == expected_url, "GitHub API URL should be correct"
@pytest.mark.asyncio
async def test_check_for_updates_stores_tag_name(self):
"""Test that check_for_updates stores the tag_name when update found."""
from app.library.config import Config
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
config.new_version = ""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"tag_name": "v2.0.0"}
mock_context = AsyncMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
with patch("app.library.UpdateChecker.async_client", return_value=mock_context):
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config)
await checker.check_for_updates()
assert "v2.0.0" == config.new_version, "Should store full tag_name including 'v' prefix"
def test_subscribe_to_started_event(self):
"""Test that attach subscribes to Events.STARTED."""
import asyncio
from app.library.config import Config
from app.library.Events import EventBus
from app.library.Scheduler import Scheduler
from app.library.UpdateChecker import UpdateChecker
config = Config.get_instance()
config.check_for_updates = True
loop = asyncio.new_event_loop()
scheduler = Scheduler.get_instance(loop=loop)
notify = EventBus.get_instance()
checker = None
try:
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config, scheduler=scheduler)
app_mock = MagicMock()
checker.attach(app_mock)
# Verify subscription was created
subscriptions = notify._listeners.get("started", [])
assert len(subscriptions) > 0, "Should have subscribed to STARTED event"
assert any("UpdateChecker.attach" in name for name, _ in subscriptions), (
"Should have UpdateChecker.attach subscription"
)
finally:
if checker and checker._job_id:
scheduler.remove(checker._job_id)
loop.close()