diff --git a/Backend/app/database/DatabaseConnectionManager.py b/Backend/app/database/DatabaseConnectionManager.py index 44668e22..d54e5132 100644 --- a/Backend/app/database/DatabaseConnectionManager.py +++ b/Backend/app/database/DatabaseConnectionManager.py @@ -3,7 +3,11 @@ from pymongo.collection import Collection from app.common.app_schema import AppEnvironmentMode -from app.database.database_schema import BaseDatabaseConnection, DatabaseCollection +from app.database.database_schema import ( + BaseDatabaseConnection, + DatabaseCollection, + DatabasePingFailedException, +) from app.database.DatabaseProductionConnection import DatabaseProductionConnection from app.database.DatabaseTestingConnection import DatabaseTestingConnection from app.logging.logging_constants import LOGGING_DATABASE_MANAGER @@ -41,15 +45,38 @@ def get_collection_connection(cls, collection_name: DatabaseCollection) -> Colle def init_database_connection( cls, environment: AppEnvironmentMode, connection_uri: str ) -> None: - """Initializes the database connection and loads its unique instance\ - based on current environment value + """ + Initializes the database connection. Args: - environment (AppEnvironmentMode): the current environment value - connection_uri (str): the database connection uri + environment (AppEnvironmentMode): The current environment mode. + connection_uri (str): The URI for connecting to the database. + + Returns: + None """ + from pymongo import MongoClient + database_connection_class = cls.database_connection_mapping.get( environment, DatabaseProductionConnection ) database_connection_class.init_connection(connection_uri) cls.connection = database_connection_class + cls.client = MongoClient(connection_uri) + + @classmethod + def ping_database(cls) -> bool: + """Pings the database to check the connection + + Raises: + DatabasePingFailedException: if ping has failed + + Returns: + bool: True if the database is reachable, False otherwise + """ + try: + cls.client.admin.command("ping") + except DatabasePingFailedException: + return False + else: + return True diff --git a/Backend/app/logging/logging_constants.py b/Backend/app/logging/logging_constants.py index 0bab6fbf..17c5eff4 100644 --- a/Backend/app/logging/logging_constants.py +++ b/Backend/app/logging/logging_constants.py @@ -75,3 +75,4 @@ # Utilities LOGGING_AUDIO_MANAGEMENT_UTILS = "AUDIO_MANAGEMENT_UTILS" +LOGGING_HEALTH = "HEALTH" diff --git a/Backend/app/spotify_electron/health/health_controller.py b/Backend/app/spotify_electron/health/health_controller.py index f81aee15..ed6df76e 100644 --- a/Backend/app/spotify_electron/health/health_controller.py +++ b/Backend/app/spotify_electron/health/health_controller.py @@ -3,19 +3,119 @@ """ from fastapi import APIRouter -from fastapi.responses import Response -from starlette.status import HTTP_200_OK +from fastapi.responses import JSONResponse +from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE + +from app.auth.auth_schema import AuthConfig +from app.database.database_schema import DatabasePingFailedException +from app.database.DatabaseConnectionManager import DatabaseConnectionManager +from app.logging.logging_constants import LOGGING_HEALTH +from app.logging.logging_schema import SpotifyElectronLogger +from app.spotify_electron.health.health_schema import HealthCheckResponse +from app.spotify_electron.song.providers.song_service_provider import SongServiceProvider router = APIRouter(prefix="/health", tags=["health"]) +logger = SpotifyElectronLogger(LOGGING_HEALTH).getLogger() + + +def check_database_connection() -> dict[str, str]: + """Validates ping to database + + Returns: + dict[str, str]: Dictionary with status and explanatory message + """ + try: + if DatabaseConnectionManager.ping_database(): + return {"status": "healthy", "message": "Database connection is active"} + except DatabasePingFailedException: + logger.exception(DatabasePingFailedException.ERROR) + else: + return {"status": "unhealthy", "message": DatabasePingFailedException.ERROR} + + +def check_song_service() -> dict[str, str]: + """Validates if SongServiceProvider is inited + + Returns: + dict[str, str]: Dictionary with status and explanatory message + """ + try: + service = SongServiceProvider.get_song_service() + except Exception as exception: + logger.exception("SongServiceProvider health check failed") + return { + "status": "unhealthy", + "message": f"SongServiceProvider check failed: {str(exception)}", + } + else: + if service is None: + return {"status": "unhealthy", "message": "SongServiceProvider is not initialized"} + return {"status": "healthy", "message": "SongServiceProvider is properly initialized"} -@router.get("/", summary="Health Check Endpoint") -def get_health() -> Response: - """Validates if the app has launched correctly +def check_auth_config() -> dict[str, str]: + """Validates if auth configurations are set - Returns - ------- - Response 200 OK + Returns: + dict[str, str]: Dictionary with status and explanatory message + """ + try: + if not all( + [ + hasattr(AuthConfig, "VERTIFICATION_ALGORITHM") + and AuthConfig.VERTIFICATION_ALGORITHM, + hasattr(AuthConfig, "ACCESS_TOKEN_EXPIRE_MINUTES") + and AuthConfig.ACCESS_TOKEN_EXPIRE_MINUTES, + hasattr(AuthConfig, "DAYS_TO_EXPIRE_COOKIE") + and AuthConfig.DAYS_TO_EXPIRE_COOKIE, + ] + ): + return { + "status": "unhealthy", + "message": "Auth configuration is not fully initialized", + } + except Exception: + logger.exception("Auth configuration health check failed") + return { + "status": "unhealthy", + "message": "Auth configuration check failed", + } + else: + return {"status": "healthy", "message": "Auth configuration is properly initialized"} + + +@router.get( + "/", + response_model=HealthCheckResponse, + summary="Health Check Endpoint", + description="Validates if the app and its critical components are functioning correctly", +) +async def get_health() -> JSONResponse: + """Validates if the app has launched correctly and all critical components are healthy + Returns: + JSONResponse: health status with details of each component and status code """ - return Response(status_code=HTTP_200_OK, content="OK", media_type="text/plain") + db_health = check_database_connection() + service_health = check_song_service() + auth_health = check_auth_config() + + health_details = { + "database": db_health, + "song_service": service_health, + "auth_config": auth_health, + } + + is_healthy = all(check["status"] == "healthy" for check in health_details.values()) + + response_data = { + "status": "healthy" if is_healthy else "unhealthy", + "details": health_details, + } + + status_code = HTTP_200_OK if is_healthy else HTTP_503_SERVICE_UNAVAILABLE + + if not is_healthy: + logger.warning(f"Health check failed: {health_details}") + + return JSONResponse(content=response_data, status_code=status_code) diff --git a/Backend/app/spotify_electron/health/health_schema.py b/Backend/app/spotify_electron/health/health_schema.py new file mode 100644 index 00000000..c3f90575 --- /dev/null +++ b/Backend/app/spotify_electron/health/health_schema.py @@ -0,0 +1,15 @@ +""" +This module defines the schema for health check responses. + +It includes the `HealthCheckResponse` model, which represents the structure +of responses for system health checks. +""" + +from pydantic import BaseModel + + +class HealthCheckResponse(BaseModel): + """Class that represents response of health check""" + + status: str + details: dict[str, dict[str, str]] diff --git a/Backend/tests/test__health.py b/Backend/tests/test__health.py index dc73010a..280e4f4b 100644 --- a/Backend/tests/test__health.py +++ b/Backend/tests/test__health.py @@ -1,6 +1,6 @@ from fastapi.testclient import TestClient from pytest import fixture -from starlette.status import HTTP_200_OK +from starlette.status import HTTP_200_OK, HTTP_503_SERVICE_UNAVAILABLE from app.__main__ import app @@ -9,10 +9,141 @@ @fixture(scope="module", autouse=True) def set_up(trigger_app_startup): + """Setup fixture to initialize the app.""" pass -def test_health_check(): +def mock_check_database_connection(): + """Mock database connection check.""" + return {"status": "healthy", "message": "Mocked database connection is active"} + + +def mock_check_song_service(): + """Mock song service check.""" + return {"status": "healthy", "message": "Mocked SongService is properly initialized"} + + +def mock_check_auth_config(): + """Mock auth config check.""" + return { + "status": "healthy", + "message": "Mocked Auth configuration is properly initialized", + } + + +def test_health_check_all_healthy(mocker): + """Test health check endpoint when all components are healthy.""" + # Mock dependencies + mocker.patch( + "app.spotify_electron.health.health_controller.check_database_connection", + return_value=mock_check_database_connection(), + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_song_service", + return_value=mock_check_song_service(), + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_auth_config", + return_value=mock_check_auth_config(), + ) + response = client.get("/health/") assert response.status_code == HTTP_200_OK - assert response.text == "OK" + response_json = response.json() + + assert response_json["status"] == "healthy" + assert response_json["details"]["database"]["status"] == "healthy" + assert response_json["details"]["song_service"]["status"] == "healthy" + assert response_json["details"]["auth_config"]["status"] == "healthy" + + +def test_health_check_unhealthy_database(mocker): + """Test health check endpoint when the database connection is unhealthy.""" + # Mock dependencies + mocker.patch( + "app.spotify_electron.health.health_controller.check_database_connection", + return_value={"status": "unhealthy", "message": "Mocked database connection failed"}, + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_song_service", + return_value=mock_check_song_service(), + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_auth_config", + return_value=mock_check_auth_config(), + ) + + response = client.get("/health/") + assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE + response_json = response.json() + + assert response_json["status"] == "unhealthy" + assert response_json["details"]["database"]["status"] == "unhealthy" + assert ( + response_json["details"]["database"]["message"] == "Mocked database connection failed" + ) + assert response_json["details"]["song_service"]["status"] == "healthy" + assert response_json["details"]["auth_config"]["status"] == "healthy" + + +def test_health_check_unhealthy_song_service(mocker): + """Test health check endpoint when the SongService is unhealthy.""" + # Mock dependencies + mocker.patch( + "app.spotify_electron.health.health_controller.check_database_connection", + return_value=mock_check_database_connection(), + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_song_service", + return_value={ + "status": "unhealthy", + "message": "Mocked SongService initialization failed", + }, + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_auth_config", + return_value=mock_check_auth_config(), + ) + + response = client.get("/health/") + assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE + response_json = response.json() + + assert response_json["status"] == "unhealthy" + assert response_json["details"]["song_service"]["status"] == "unhealthy" + assert ( + response_json["details"]["song_service"]["message"] + == "Mocked SongService initialization failed" + ) + assert response_json["details"]["database"]["status"] == "healthy" + assert response_json["details"]["auth_config"]["status"] == "healthy" + + +def test_health_check_unhealthy_auth_config(mocker): + """Test health check endpoint when the Auth configuration is unhealthy.""" + # Mock dependencies + mocker.patch( + "app.spotify_electron.health.health_controller.check_database_connection", + return_value=mock_check_database_connection(), + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_song_service", + return_value=mock_check_song_service(), + ) + mocker.patch( + "app.spotify_electron.health.health_controller.check_auth_config", + return_value={"status": "unhealthy", "message": "Mocked Auth configuration failed"}, + ) + + response = client.get("/health/") + assert response.status_code == HTTP_503_SERVICE_UNAVAILABLE + response_json = response.json() + + assert response_json["status"] == "unhealthy" + assert response_json["details"]["auth_config"]["status"] == "unhealthy" + assert ( + response_json["details"]["auth_config"]["message"] + == "Mocked Auth configuration failed" + ) + assert response_json["details"]["database"]["status"] == "healthy" + assert response_json["details"]["song_service"]["status"] == "healthy" diff --git a/Electron/src/swagger/openapi.json b/Electron/src/swagger/openapi.json index 7b99f744..438a41b5 100644 --- a/Electron/src/swagger/openapi.json +++ b/Electron/src/swagger/openapi.json @@ -1632,14 +1632,16 @@ "health" ], "summary": "Health Check Endpoint", - "description": "Validates if the app has launched correctly\n\nReturns\n-------\n Response 200 OK", + "description": "Validates if the app and its critical components are functioning correctly", "operationId": "get_health_health__get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/HealthCheckResponse" + } } } } @@ -1770,6 +1772,31 @@ "type": "object", "title": "HTTPValidationError" }, + "HealthCheckResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "details": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object", + "title": "Details" + } + }, + "type": "object", + "required": [ + "status", + "details" + ], + "title": "HealthCheckResponse", + "description": "Class that represents response of health check" + }, "ValidationError": { "properties": { "loc": {