Add startup health checks and /health/ready endpoint
- Add _run_startup_health_checks() function in fastapi_app.py - Check ffmpeg availability (warning) - Check DNS resolution for aniworld.to and api.themoviedb.org (warning) - Check anime_directory configuration and writability (error) - Store startup checks in app.state for health endpoint access - Add /health/ready endpoint for container orchestrators - Returns not_ready with 503 when critical failures present - Includes critical_failures list for debugging - Update /health endpoint to include startup check results - Status reflects worst check (error > warning > ok) - Document health check endpoints in DEVELOPMENT.md - Add unit tests for startup health checks - Add unit tests for /health/ready endpoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for health check endpoints."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -12,16 +12,20 @@ from src.server.api.health import (
|
||||
check_database_health,
|
||||
check_filesystem_health,
|
||||
get_system_metrics,
|
||||
ready_check,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_health_check():
|
||||
"""Test basic health check endpoint."""
|
||||
async def test_basic_health_check_no_startup_checks():
|
||||
"""Test basic health check endpoint with no startup checks."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {}
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings, \
|
||||
patch("src.server.utils.dependencies._series_app", None):
|
||||
mock_settings.anime_directory = ""
|
||||
result = await basic_health_check()
|
||||
result = await basic_health_check(mock_request)
|
||||
|
||||
assert isinstance(result, HealthStatus)
|
||||
assert result.status == "healthy"
|
||||
@@ -32,6 +36,85 @@ async def test_basic_health_check():
|
||||
assert result.anime_directory_configured is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_health_check_with_error_check():
|
||||
"""Test basic health check reflects error status from startup checks."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "error", "message": "not configured", "path": None},
|
||||
"ffmpeg": {"status": "ok", "message": "Found at /usr/bin/ffmpeg"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings, \
|
||||
patch("src.server.utils.dependencies._series_app", None):
|
||||
mock_settings.anime_directory = ""
|
||||
result = await basic_health_check(mock_request)
|
||||
|
||||
assert isinstance(result, HealthStatus)
|
||||
assert result.status == "unhealthy"
|
||||
assert result.checks is not None
|
||||
assert result.checks["anime_directory"]["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_health_check_with_warning_only():
|
||||
"""Test basic health check shows degraded when only warnings present."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "ok", "message": "Found", "path": "/anime"},
|
||||
"ffmpeg": {"status": "warning", "message": "not found in PATH"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings, \
|
||||
patch("src.server.utils.dependencies._series_app", None):
|
||||
mock_settings.anime_directory = "/anime"
|
||||
result = await basic_health_check(mock_request)
|
||||
|
||||
assert isinstance(result, HealthStatus)
|
||||
assert result.status == "degraded"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_check_all_healthy():
|
||||
"""Test ready check returns ready when all checks pass."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "ok", "message": "Found", "path": "/anime"},
|
||||
"ffmpeg": {"status": "ok", "message": "Found at /usr/bin/ffmpeg"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
result = await ready_check(mock_request)
|
||||
|
||||
assert result["ready"] is True
|
||||
assert result["status"] == "ready"
|
||||
assert "critical_failures" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_check_with_critical_failure():
|
||||
"""Test ready check returns not_ready when anime_directory not configured."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.app.state.startup_checks = {
|
||||
"anime_directory": {"status": "error", "message": "not configured", "path": None},
|
||||
"ffmpeg": {"status": "warning", "message": "not found in PATH"},
|
||||
"dns_aniworld": {"status": "ok", "message": "Resolved successfully"},
|
||||
"dns_tmdb": {"status": "ok", "message": "Resolved successfully"},
|
||||
}
|
||||
|
||||
result = await ready_check(mock_request)
|
||||
|
||||
assert result["ready"] is False
|
||||
assert result["status"] == "not_ready"
|
||||
assert len(result["critical_failures"]) == 1
|
||||
assert "anime_directory" in result["critical_failures"][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_health_check_success():
|
||||
"""Test database health check with successful connection."""
|
||||
|
||||
135
tests/unit/test_startup_health_checks.py
Normal file
135
tests/unit/test_startup_health_checks.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Unit tests for startup health checks in fastapi_app.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestStartupHealthChecks:
|
||||
"""Test startup health check function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_missing_sets_warning(self):
|
||||
"""Test ffmpeg missing results in warning status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("shutil.which", return_value=None):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["ffmpeg"]["status"] == "warning"
|
||||
assert "not found in PATH" in result["ffmpeg"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffmpeg_present_sets_ok(self):
|
||||
"""Test ffmpeg present results in ok status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["ffmpeg"]["status"] == "ok"
|
||||
assert "Found at" in result["ffmpeg"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_not_configured_sets_error(self):
|
||||
"""Test anime_directory not configured results in error status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = ""
|
||||
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "error"
|
||||
assert result["anime_directory"]["path"] is None
|
||||
assert "not configured" in result["anime_directory"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_not_exists_sets_error(self):
|
||||
"""Test anime_directory path not existing results in error status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = "/nonexistent/path"
|
||||
|
||||
with patch("os.path.isdir", return_value=False):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "error"
|
||||
assert "does not exist" in result["anime_directory"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_not_writable_sets_error(self):
|
||||
"""Test anime_directory not writable results in error status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = "/some/path"
|
||||
|
||||
with patch("os.path.isdir", return_value=True):
|
||||
with patch("os.access", return_value=False):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "error"
|
||||
assert "not writable" in result["anime_directory"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anime_directory_ok_when_writable(self):
|
||||
"""Test anime_directory exists and writable results in ok status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = "/valid/path"
|
||||
|
||||
with patch("os.path.isdir", return_value=True):
|
||||
with patch("os.access", return_value=True):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["anime_directory"]["status"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dns_aniworld_failure_sets_warning(self):
|
||||
"""Test DNS failure for aniworld.to sets warning status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
import socket
|
||||
with patch("socket.gethostbyname", side_effect=socket.gaierror("DNS failed")):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["dns_aniworld"]["status"] == "warning"
|
||||
assert "DNS resolution failed" in result["dns_aniworld"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dns_tmdb_failure_sets_warning(self):
|
||||
"""Test DNS failure for api.themoviedb.org sets warning status."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
import socket
|
||||
with patch("socket.gethostbyname", side_effect=socket.gaierror("DNS failed")):
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert result["dns_tmdb"]["status"] == "warning"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_checks_returned(self):
|
||||
"""Test all health checks are present in result."""
|
||||
mock_logger = MagicMock()
|
||||
|
||||
with patch("src.config.settings.settings") as mock_settings:
|
||||
mock_settings.anime_directory = ""
|
||||
|
||||
from src.server.fastapi_app import _run_startup_health_checks
|
||||
result = await _run_startup_health_checks(mock_logger)
|
||||
|
||||
assert "ffmpeg" in result
|
||||
assert "dns_aniworld" in result
|
||||
assert "dns_tmdb" in result
|
||||
assert "anime_directory" in result
|
||||
Reference in New Issue
Block a user