- Created StartupDAG class to orchestrate startup stages with explicit dependencies - Defined 6 startup stages: WORKER_MODE → DATABASE → GEO_CACHE → HTTP_SESSION → SCHEDULER → TASKS - Each stage has prerequisites, error handling, and rollback support - Refactored startup_shared_resources() to use the DAG - Added StartupContext for resource tracking and failure management - Partial failures automatically roll back all completed resources in reverse order - Added health checks to verify all resources initialized successfully - Comprehensive test coverage: 15 DAG unit tests + 3 integration tests + 6 existing tests - Documented startup DAG in Architekture.md with detailed stage descriptions and failure modes This replaces implicit ordering with explicit dependency tracking, making lifecycle changes safe and failure modes predictable. Hidden order dependencies no longer exist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
189 lines
7.0 KiB
Python
189 lines
7.0 KiB
Python
"""Integration tests for the complete startup flow with StartupDAG."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
|
|
from app.config import Settings
|
|
from app.startup import startup_shared_resources
|
|
|
|
|
|
def _create_test_settings(tmpdir: str) -> Settings:
|
|
"""Create a minimal Settings object for testing."""
|
|
return Settings(
|
|
database_path=str(Path(tmpdir) / "bangui.db"),
|
|
fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
|
|
session_secret="test-secret-12345678901234567890",
|
|
fail2ban_config_dir="/etc/fail2ban",
|
|
geoip_db_path="/usr/share/GeoIP/GeoLite2-Country.mmdb",
|
|
geoip_allow_http_fallback=False,
|
|
log_level="info",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_shared_resources_complete_flow() -> None:
|
|
"""Test that startup_shared_resources successfully initializes all resources via DAG."""
|
|
# Create a test app
|
|
app = FastAPI()
|
|
app.state = MagicMock()
|
|
|
|
# Create minimal settings for testing
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
settings = _create_test_settings(tmpdir)
|
|
|
|
# Mock external dependencies that would require actual fail2ban/MaxMind
|
|
with patch("app.startup.open_db") as mock_open_db, patch(
|
|
"app.startup.init_db"
|
|
) as mock_init_db, patch(
|
|
"app.startup.setup_service.is_setup_complete"
|
|
) as mock_is_setup_complete, patch(
|
|
"app.startup.set_setup_complete_cache"
|
|
) as mock_set_setup_complete, patch(
|
|
"app.startup.GeoCache"
|
|
) as mock_geo_cache_class, patch(
|
|
"app.startup.ensure_jail_configs"
|
|
) as mock_ensure_jail, patch(
|
|
"app.startup.health_check.register"
|
|
) as mock_health_check_register, patch(
|
|
"app.startup.blocklist_import.register"
|
|
) as mock_blocklist_import_register, patch(
|
|
"app.startup.geo_cache_cleanup.register"
|
|
) as mock_geo_cache_cleanup_register, patch(
|
|
"app.startup.geo_cache_flush.register"
|
|
) as mock_geo_cache_flush_register, patch(
|
|
"app.startup.geo_re_resolve.register"
|
|
) as mock_geo_re_resolve_register, patch(
|
|
"app.startup.history_sync.register"
|
|
) as mock_history_sync_register, patch(
|
|
"app.startup.session_cleanup.register"
|
|
) as mock_session_cleanup_register:
|
|
|
|
# Setup mock database
|
|
mock_db = AsyncMock()
|
|
mock_db.close = AsyncMock()
|
|
mock_open_db.return_value = mock_db
|
|
|
|
# Setup mock services
|
|
mock_init_db.return_value = None
|
|
mock_is_setup_complete.return_value = False
|
|
mock_set_setup_complete.return_value = None
|
|
|
|
# Setup mock GeoCache
|
|
mock_geo_cache = MagicMock()
|
|
mock_geo_cache.load_cache_from_db = AsyncMock()
|
|
mock_geo_cache.count_unresolved = AsyncMock(return_value=0)
|
|
mock_geo_cache.init_geoip = MagicMock()
|
|
mock_geo_cache_class.return_value = mock_geo_cache
|
|
|
|
# Setup mock blocklist import (async function)
|
|
mock_blocklist_import_register.return_value = None
|
|
|
|
# Call startup_shared_resources
|
|
http_session, scheduler = await startup_shared_resources(app, settings)
|
|
|
|
# Verify all stages completed successfully
|
|
assert http_session is not None
|
|
assert scheduler is not None
|
|
assert scheduler.running
|
|
|
|
# Verify resources were initialized
|
|
assert app.state.geo_cache is mock_geo_cache
|
|
|
|
# Verify all task registration functions were called
|
|
mock_health_check_register.assert_called_once()
|
|
mock_blocklist_import_register.assert_called_once()
|
|
mock_geo_cache_cleanup_register.assert_called_once()
|
|
mock_geo_cache_flush_register.assert_called_once()
|
|
mock_geo_re_resolve_register.assert_called_once()
|
|
mock_history_sync_register.assert_called_once()
|
|
mock_session_cleanup_register.assert_called_once()
|
|
|
|
# Cleanup
|
|
await http_session.close()
|
|
scheduler.shutdown(wait=False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_shared_resources_rollback_on_database_failure() -> None:
|
|
"""Test that startup_shared_resources rolls back all resources if database init fails."""
|
|
app = FastAPI()
|
|
app.state = MagicMock()
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
settings = _create_test_settings(tmpdir)
|
|
|
|
with patch("app.startup.open_db") as mock_open_db, patch(
|
|
"app.startup.init_db"
|
|
) as mock_init_db:
|
|
|
|
# Setup mock database to fail
|
|
mock_db = AsyncMock()
|
|
mock_db.close = AsyncMock()
|
|
mock_open_db.return_value = mock_db
|
|
mock_init_db.side_effect = RuntimeError("Database initialization failed")
|
|
|
|
# startup_shared_resources should raise the database error
|
|
with pytest.raises(RuntimeError, match="Database initialization failed"):
|
|
await startup_shared_resources(app, settings)
|
|
|
|
# Verify cleanup was attempted
|
|
mock_db.close.assert_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_shared_resources_scheduler_starts() -> None:
|
|
"""Test that the scheduler is started during startup."""
|
|
app = FastAPI()
|
|
app.state = MagicMock()
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
settings = _create_test_settings(tmpdir)
|
|
|
|
with patch("app.startup.open_db") as mock_open_db, patch(
|
|
"app.startup.init_db"
|
|
), patch("app.startup.setup_service.is_setup_complete") as mock_is_setup, patch(
|
|
"app.startup.set_setup_complete_cache"
|
|
), patch(
|
|
"app.startup.GeoCache"
|
|
) as mock_geo_cache_class, patch(
|
|
"app.startup.ensure_jail_configs"
|
|
), patch(
|
|
"app.startup.health_check.register"
|
|
), patch(
|
|
"app.startup.blocklist_import.register"
|
|
), patch(
|
|
"app.startup.geo_cache_cleanup.register"
|
|
), patch(
|
|
"app.startup.geo_cache_flush.register"
|
|
), patch(
|
|
"app.startup.geo_re_resolve.register"
|
|
), patch(
|
|
"app.startup.history_sync.register"
|
|
), patch(
|
|
"app.startup.session_cleanup.register"
|
|
):
|
|
|
|
mock_db = AsyncMock()
|
|
mock_db.close = AsyncMock()
|
|
mock_open_db.return_value = mock_db
|
|
mock_is_setup.return_value = False
|
|
|
|
mock_geo_cache = MagicMock()
|
|
mock_geo_cache.load_cache_from_db = AsyncMock()
|
|
mock_geo_cache.count_unresolved = AsyncMock(return_value=0)
|
|
mock_geo_cache.init_geoip = MagicMock()
|
|
mock_geo_cache_class.return_value = mock_geo_cache
|
|
|
|
http_session, scheduler = await startup_shared_resources(app, settings)
|
|
|
|
# Verify scheduler is running
|
|
assert scheduler.running
|
|
|
|
# Cleanup
|
|
await http_session.close()
|
|
scheduler.shutdown(wait=False)
|