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:
2026-05-23 22:12:03 +02:00
parent 9a20541598
commit 3551838887
5 changed files with 458 additions and 7 deletions

View File

@@ -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."""

View 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