mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-30 21:49:30 -05:00
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>
This commit is contained in:
271
python/tests/server/services/test_migration_service.py
Normal file
271
python/tests/server/services/test_migration_service.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Fixed unit tests for migration_service.py
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.server.config.version import ARCHON_VERSION
|
||||
from src.server.services.migration_service import (
|
||||
MigrationRecord,
|
||||
MigrationService,
|
||||
PendingMigration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migration_service():
|
||||
"""Create a migration service instance."""
|
||||
with patch("src.server.services.migration_service.Path.exists") as mock_exists:
|
||||
# Mock that migration directory exists locally
|
||||
mock_exists.return_value = False # Docker path doesn't exist
|
||||
service = MigrationService()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_supabase_client():
|
||||
"""Mock Supabase client."""
|
||||
client = MagicMock()
|
||||
return client
|
||||
|
||||
|
||||
def test_pending_migration_init():
|
||||
"""Test PendingMigration initialization and checksum calculation."""
|
||||
migration = PendingMigration(
|
||||
version="0.1.0",
|
||||
name="001_initial",
|
||||
sql_content="CREATE TABLE test (id INT);",
|
||||
file_path="migration/0.1.0/001_initial.sql"
|
||||
)
|
||||
|
||||
assert migration.version == "0.1.0"
|
||||
assert migration.name == "001_initial"
|
||||
assert migration.sql_content == "CREATE TABLE test (id INT);"
|
||||
assert migration.file_path == "migration/0.1.0/001_initial.sql"
|
||||
assert migration.checksum == hashlib.md5("CREATE TABLE test (id INT);".encode()).hexdigest()
|
||||
|
||||
|
||||
def test_migration_record_init():
|
||||
"""Test MigrationRecord initialization from database data."""
|
||||
data = {
|
||||
"id": "123-456",
|
||||
"version": "0.1.0",
|
||||
"migration_name": "001_initial",
|
||||
"applied_at": "2025-01-01T00:00:00Z",
|
||||
"checksum": "abc123"
|
||||
}
|
||||
|
||||
record = MigrationRecord(data)
|
||||
|
||||
assert record.id == "123-456"
|
||||
assert record.version == "0.1.0"
|
||||
assert record.migration_name == "001_initial"
|
||||
assert record.applied_at == "2025-01-01T00:00:00Z"
|
||||
assert record.checksum == "abc123"
|
||||
|
||||
|
||||
def test_migration_service_init_local():
|
||||
"""Test MigrationService initialization with local path."""
|
||||
with patch("src.server.services.migration_service.Path.exists") as mock_exists:
|
||||
# Mock that Docker path doesn't exist
|
||||
mock_exists.return_value = False
|
||||
|
||||
service = MigrationService()
|
||||
assert service._migrations_dir == Path("migration")
|
||||
|
||||
|
||||
def test_migration_service_init_docker():
|
||||
"""Test MigrationService initialization with Docker path."""
|
||||
with patch("src.server.services.migration_service.Path.exists") as mock_exists:
|
||||
# Mock that Docker path exists
|
||||
mock_exists.return_value = True
|
||||
|
||||
service = MigrationService()
|
||||
assert service._migrations_dir == Path("/app/migration")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applied_migrations_success(migration_service, mock_supabase_client):
|
||||
"""Test successful retrieval of applied migrations."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [
|
||||
{
|
||||
"id": "123",
|
||||
"version": "0.1.0",
|
||||
"migration_name": "001_initial",
|
||||
"applied_at": "2025-01-01T00:00:00Z",
|
||||
"checksum": "abc123",
|
||||
},
|
||||
]
|
||||
|
||||
mock_supabase_client.table.return_value.select.return_value.order.return_value.execute.return_value = mock_response
|
||||
|
||||
with patch.object(migration_service, '_get_supabase_client', return_value=mock_supabase_client):
|
||||
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
|
||||
result = await migration_service.get_applied_migrations()
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], MigrationRecord)
|
||||
assert result[0].version == "0.1.0"
|
||||
assert result[0].migration_name == "001_initial"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applied_migrations_table_not_exists(migration_service, mock_supabase_client):
|
||||
"""Test handling when migrations table doesn't exist."""
|
||||
with patch.object(migration_service, '_get_supabase_client', return_value=mock_supabase_client):
|
||||
with patch.object(migration_service, 'check_migrations_table_exists', return_value=False):
|
||||
result = await migration_service.get_applied_migrations()
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_migrations_with_files(migration_service, mock_supabase_client):
|
||||
"""Test getting pending migrations from filesystem."""
|
||||
# Mock scan_migration_directory to return test migrations
|
||||
mock_migrations = [
|
||||
PendingMigration(
|
||||
version="0.1.0",
|
||||
name="001_initial",
|
||||
sql_content="CREATE TABLE test;",
|
||||
file_path="migration/0.1.0/001_initial.sql"
|
||||
),
|
||||
PendingMigration(
|
||||
version="0.1.0",
|
||||
name="002_update",
|
||||
sql_content="ALTER TABLE test ADD col TEXT;",
|
||||
file_path="migration/0.1.0/002_update.sql"
|
||||
)
|
||||
]
|
||||
|
||||
# Mock no applied migrations
|
||||
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_migrations):
|
||||
with patch.object(migration_service, 'get_applied_migrations', return_value=[]):
|
||||
result = await migration_service.get_pending_migrations()
|
||||
|
||||
assert len(result) == 2
|
||||
assert all(isinstance(m, PendingMigration) for m in result)
|
||||
assert result[0].name == "001_initial"
|
||||
assert result[1].name == "002_update"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_migrations_some_applied(migration_service, mock_supabase_client):
|
||||
"""Test getting pending migrations when some are already applied."""
|
||||
# Mock all migrations
|
||||
mock_all_migrations = [
|
||||
PendingMigration(
|
||||
version="0.1.0",
|
||||
name="001_initial",
|
||||
sql_content="CREATE TABLE test;",
|
||||
file_path="migration/0.1.0/001_initial.sql"
|
||||
),
|
||||
PendingMigration(
|
||||
version="0.1.0",
|
||||
name="002_update",
|
||||
sql_content="ALTER TABLE test ADD col TEXT;",
|
||||
file_path="migration/0.1.0/002_update.sql"
|
||||
)
|
||||
]
|
||||
|
||||
# Mock first migration as applied
|
||||
mock_applied = [
|
||||
MigrationRecord({
|
||||
"version": "0.1.0",
|
||||
"migration_name": "001_initial",
|
||||
"applied_at": "2025-01-01T00:00:00Z",
|
||||
"checksum": None
|
||||
})
|
||||
]
|
||||
|
||||
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_all_migrations):
|
||||
with patch.object(migration_service, 'get_applied_migrations', return_value=mock_applied):
|
||||
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
|
||||
result = await migration_service.get_pending_migrations()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "002_update"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_migration_status_all_applied(migration_service, mock_supabase_client):
|
||||
"""Test migration status when all migrations are applied."""
|
||||
# Mock one migration file
|
||||
mock_all_migrations = [
|
||||
PendingMigration(
|
||||
version="0.1.0",
|
||||
name="001_initial",
|
||||
sql_content="CREATE TABLE test;",
|
||||
file_path="migration/0.1.0/001_initial.sql"
|
||||
)
|
||||
]
|
||||
|
||||
# Mock migration as applied
|
||||
mock_applied = [
|
||||
MigrationRecord({
|
||||
"version": "0.1.0",
|
||||
"migration_name": "001_initial",
|
||||
"applied_at": "2025-01-01T00:00:00Z",
|
||||
"checksum": None
|
||||
})
|
||||
]
|
||||
|
||||
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_all_migrations):
|
||||
with patch.object(migration_service, 'get_applied_migrations', return_value=mock_applied):
|
||||
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
|
||||
result = await migration_service.get_migration_status()
|
||||
|
||||
assert result["current_version"] == ARCHON_VERSION
|
||||
assert result["has_pending"] is False
|
||||
assert result["bootstrap_required"] is False
|
||||
assert result["pending_count"] == 0
|
||||
assert result["applied_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_migration_status_bootstrap_required(migration_service, mock_supabase_client):
|
||||
"""Test migration status when bootstrap is required (table doesn't exist)."""
|
||||
# Mock migration files
|
||||
mock_all_migrations = [
|
||||
PendingMigration(
|
||||
version="0.1.0",
|
||||
name="001_initial",
|
||||
sql_content="CREATE TABLE test;",
|
||||
file_path="migration/0.1.0/001_initial.sql"
|
||||
),
|
||||
PendingMigration(
|
||||
version="0.1.0",
|
||||
name="002_update",
|
||||
sql_content="ALTER TABLE test ADD col TEXT;",
|
||||
file_path="migration/0.1.0/002_update.sql"
|
||||
)
|
||||
]
|
||||
|
||||
with patch.object(migration_service, 'scan_migration_directory', return_value=mock_all_migrations):
|
||||
with patch.object(migration_service, 'get_applied_migrations', return_value=[]):
|
||||
with patch.object(migration_service, 'check_migrations_table_exists', return_value=False):
|
||||
result = await migration_service.get_migration_status()
|
||||
|
||||
assert result["bootstrap_required"] is True
|
||||
assert result["has_pending"] is True
|
||||
assert result["pending_count"] == 2
|
||||
assert result["applied_count"] == 0
|
||||
assert len(result["pending_migrations"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_migration_status_no_files(migration_service, mock_supabase_client):
|
||||
"""Test migration status when no migration files exist."""
|
||||
with patch.object(migration_service, 'scan_migration_directory', return_value=[]):
|
||||
with patch.object(migration_service, 'get_applied_migrations', return_value=[]):
|
||||
with patch.object(migration_service, 'check_migrations_table_exists', return_value=True):
|
||||
result = await migration_service.get_migration_status()
|
||||
|
||||
assert result["has_pending"] is False
|
||||
assert result["pending_count"] == 0
|
||||
assert len(result["pending_migrations"]) == 0
|
||||
234
python/tests/server/services/test_version_service.py
Normal file
234
python/tests/server/services/test_version_service.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user