Archon/python/tests/server/services/test_version_service.py
Cole Medin 3ff3f7f2dc
Migrations and version APIs (#718)
* Preparing migration folder for the migration alert implementation

* Migrations and version APIs initial

* Touching up update instructions in README and UI

* Unit tests for migrations and version APIs

* Splitting up the Ollama migration scripts

* Removing temporary PRPs

---------

Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
2025-09-22 12:25:58 +03:00

234 lines
8.5 KiB
Python

"""
Unit tests for version_service.py
"""
import json
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from src.server.config.version import ARCHON_VERSION
from src.server.services.version_service import VersionService
@pytest.fixture
def version_service():
"""Create a fresh version service instance for each test."""
service = VersionService()
# Clear any cache from previous tests
service._cache = None
service._cache_time = None
return service
@pytest.fixture
def mock_release_data():
"""Mock GitHub release data."""
return {
"tag_name": "v0.2.0",
"name": "Archon v0.2.0",
"html_url": "https://github.com/coleam00/Archon/releases/tag/v0.2.0",
"body": "## Release Notes\n\nNew features and bug fixes",
"published_at": "2025-01-01T00:00:00Z",
"author": {"login": "coleam00"},
"assets": [
{
"name": "archon-v0.2.0.zip",
"size": 1024000,
"download_count": 100,
"browser_download_url": "https://github.com/coleam00/Archon/releases/download/v0.2.0/archon-v0.2.0.zip",
"content_type": "application/zip",
}
],
}
@pytest.mark.asyncio
async def test_get_latest_release_success(version_service, mock_release_data):
"""Test successful fetching of latest release from GitHub."""
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_release_data
mock_client.get.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
assert result == mock_release_data
assert version_service._cache == mock_release_data
assert version_service._cache_time is not None
@pytest.mark.asyncio
async def test_get_latest_release_uses_cache(version_service, mock_release_data):
"""Test that cache is used when available and not expired."""
# Set up cache
version_service._cache = mock_release_data
version_service._cache_time = datetime.now()
with patch("httpx.AsyncClient") as mock_client_class:
result = await version_service.get_latest_release()
# Should not make HTTP request
mock_client_class.assert_not_called()
assert result == mock_release_data
@pytest.mark.asyncio
async def test_get_latest_release_cache_expired(version_service, mock_release_data):
"""Test that cache is refreshed when expired."""
# Set up expired cache
old_data = {"tag_name": "v0.1.0"}
version_service._cache = old_data
version_service._cache_time = datetime.now() - timedelta(hours=2)
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_release_data
mock_client.get.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
# Should make new HTTP request
mock_client.get.assert_called_once()
assert result == mock_release_data
assert version_service._cache == mock_release_data
@pytest.mark.asyncio
async def test_get_latest_release_404(version_service):
"""Test handling of 404 (no releases)."""
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 404
mock_client.get.return_value = mock_response
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
assert result is None
@pytest.mark.asyncio
async def test_get_latest_release_timeout(version_service, mock_release_data):
"""Test handling of timeout with cache fallback."""
# Set up cache
version_service._cache = mock_release_data
version_service._cache_time = datetime.now() - timedelta(hours=2) # Expired
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.TimeoutException("Timeout")
mock_client_class.return_value.__aenter__.return_value = mock_client
result = await version_service.get_latest_release()
# Should return cached data
assert result == mock_release_data
@pytest.mark.asyncio
async def test_check_for_updates_new_version_available(version_service, mock_release_data):
"""Test when a new version is available."""
with patch.object(version_service, "get_latest_release", return_value=mock_release_data):
result = await version_service.check_for_updates()
assert result["current"] == ARCHON_VERSION
assert result["latest"] == "0.2.0"
assert result["update_available"] is True
assert result["release_url"] == mock_release_data["html_url"]
assert result["release_notes"] == mock_release_data["body"]
assert result["published_at"] == datetime.fromisoformat("2025-01-01T00:00:00+00:00")
assert result["author"] == "coleam00"
assert len(result["assets"]) == 1
@pytest.mark.asyncio
async def test_check_for_updates_same_version(version_service):
"""Test when current version is up to date."""
mock_data = {"tag_name": f"v{ARCHON_VERSION}", "html_url": "test_url", "body": "notes"}
with patch.object(version_service, "get_latest_release", return_value=mock_data):
result = await version_service.check_for_updates()
assert result["current"] == ARCHON_VERSION
assert result["latest"] == ARCHON_VERSION
assert result["update_available"] is False
@pytest.mark.asyncio
async def test_check_for_updates_no_release(version_service):
"""Test when no releases are found."""
with patch.object(version_service, "get_latest_release", return_value=None):
result = await version_service.check_for_updates()
assert result["current"] == ARCHON_VERSION
assert result["latest"] is None
assert result["update_available"] is False
assert result["release_url"] is None
@pytest.mark.asyncio
async def test_check_for_updates_parse_version(version_service, mock_release_data):
"""Test version parsing with and without 'v' prefix."""
# Test with 'v' prefix
mock_release_data["tag_name"] = "v1.2.3"
with patch.object(version_service, "get_latest_release", return_value=mock_release_data):
result = await version_service.check_for_updates()
assert result["latest"] == "1.2.3"
# Test without 'v' prefix
mock_release_data["tag_name"] = "2.0.0"
with patch.object(version_service, "get_latest_release", return_value=mock_release_data):
result = await version_service.check_for_updates()
assert result["latest"] == "2.0.0"
@pytest.mark.asyncio
async def test_check_for_updates_missing_fields(version_service):
"""Test handling of incomplete release data."""
mock_data = {"tag_name": "v0.2.0"} # Minimal data
with patch.object(version_service, "get_latest_release", return_value=mock_data):
result = await version_service.check_for_updates()
assert result["latest"] == "0.2.0"
assert result["release_url"] is None
assert result["release_notes"] is None
assert result["published_at"] is None
assert result["author"] is None
assert result["assets"] == [] # Empty list, not None
def test_clear_cache(version_service, mock_release_data):
"""Test cache clearing."""
# Set up cache
version_service._cache = mock_release_data
version_service._cache_time = datetime.now()
# Clear cache
version_service.clear_cache()
assert version_service._cache is None
assert version_service._cache_time is None
def test_is_newer_version():
"""Test version comparison logic using the utility function."""
from src.server.utils.semantic_version import is_newer_version
# Test various version comparisons
assert is_newer_version("1.0.0", "2.0.0") is True
assert is_newer_version("2.0.0", "1.0.0") is False
assert is_newer_version("1.0.0", "1.0.0") is False
assert is_newer_version("1.0.0", "1.1.0") is True
assert is_newer_version("1.0.0", "1.0.1") is True
assert is_newer_version("1.2.3", "1.2.3") is False