diff --git a/src/protean/cli/__init__.py b/src/protean/cli/__init__.py index 8f2f1c35..32f693ac 100644 --- a/src/protean/cli/__init__.py +++ b/src/protean/cli/__init__.py @@ -29,6 +29,7 @@ from protean.cli.new import new from protean.cli.shell import shell from protean.exceptions import NoDomainException +from protean.server.engine import Engine from protean.utils.domain_discovery import derive_domain logger = logging.getLogger(__name__) @@ -152,10 +153,17 @@ def server( try: domain = derive_domain(domain) except NoDomainException as exc: - logger.error(f"Error loading Protean domain: {exc.args[0]}") + msg = f"Error loading Protean domain: {exc.args[0]}" + print(msg) # Required for tests to capture output + logger.error(msg) + raise typer.Abort() - from protean.server import Engine + # Traverse and initialize domain + # This will load all aggregates, entities, services, and other domain elements. + # + # By the time the handlers are invoked, the domain is fully initialized and ready to serve requests. + domain.init() engine = Engine(domain, test_mode=test_mode, debug=debug) engine.run() diff --git a/src/protean/server/engine.py b/src/protean/server/engine.py index 85c3f611..ee1d9566 100644 --- a/src/protean/server/engine.py +++ b/src/protean/server/engine.py @@ -50,6 +50,9 @@ def __init__(self, domain, test_mode: bool = False, debug: bool = False) -> None self.exit_code = 0 self.shutting_down = False # Flag to indicate the engine is shutting down + if self.debug: + logger.setLevel(logging.DEBUG) + self.loop = asyncio.get_event_loop() # Gather all handlers @@ -182,7 +185,7 @@ async def shutdown(self, signal=None, exit_code=0): try: msg = ( - "Received exit signal {signal.name}. Shutting down..." + f"Received exit signal {signal.name}. Shutting down..." if signal else "Shutting down..." ) @@ -224,16 +227,23 @@ def run(self): # Handle Exceptions def handle_exception(loop, context): - # context["message"] will always be there; but context["exception"] may not msg = context.get("exception", context["message"]) - # Print the stack trace - traceback.print_stack(context.get("exception")) + print( + f"Exception caught: {msg}" + ) # Debugging line to ensure this code path runs - logger.error(f"Caught exception: {msg}") - logger.info("Shutting down...") - if loop.is_running(): - asyncio.create_task(self.shutdown(exit_code=1)) + # Print the stack trace + if "exception" in context and context["exception"]: + traceback.print_stack(context["exception"]) + logger.error(f"Caught exception: {msg}") + logger.info("Shutting down...") + if loop.is_running(): + asyncio.create_task(self.shutdown(exit_code=1)) + + raise context["exception"] # Raise the exception to stop the loop + else: + logger.error(f"Caught exception: {msg}") self.loop.set_exception_handler(handle_exception) diff --git a/tests/cli/test_server.py b/tests/cli/test_server.py index 8a08a906..8bb8626a 100644 --- a/tests/cli/test_server.py +++ b/tests/cli/test_server.py @@ -1,11 +1,16 @@ +import asyncio +import logging import os import sys from pathlib import Path +from unittest.mock import ANY, MagicMock, patch import pytest from typer.testing import CliRunner from protean.cli import app +from protean.exceptions import NoDomainException +from protean.server.engine import Engine from tests.shared import change_working_directory_to runner = CliRunner() @@ -23,6 +28,19 @@ def reset_path(self): sys.path[:] = original_path os.chdir(cwd) + @pytest.fixture(autouse=True) + def auto_set_and_close_loop(self): + # Create and set a new loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + yield + + # Close the loop after the test + if not loop.is_closed(): + loop.close() + asyncio.set_event_loop(None) # Explicitly unset the loop + def test_server_with_invalid_domain(self): """Test that the server command fails when domain is not provided""" args = ["server", "--domain", "foobar"] @@ -31,6 +49,32 @@ def test_server_with_invalid_domain(self): assert isinstance(result.exception, SystemExit) assert "Aborted" in result.output + def test_server_with_valid_domain(self): + """Test that the server command initializes and runs with a valid domain""" + change_working_directory_to("test7") + + with patch.object(Engine, "run", return_value=None) as mock_run: + args = ["server", "--domain", "publishing7.py"] + result = runner.invoke(app, args) + + assert result.exit_code == 0 + mock_run.assert_called_once() + + def test_server_initializes_domain(self): + """Test that the server command correctly initializes the domain""" + change_working_directory_to("test7") + + with patch( + "protean.cli.derive_domain", return_value=MagicMock() + ) as mock_derive_domain: # Correct the patch path here + with patch("protean.server.engine.Engine.run") as mock_engine_run: + args = ["server", "--domain", "publishing7.py"] + result = runner.invoke(app, args) + + assert result.exit_code == 0 + mock_derive_domain.assert_called_once_with("publishing7.py") + mock_engine_run.assert_called_once() + def test_server_start_successfully(self): change_working_directory_to("test7") @@ -42,13 +86,58 @@ def test_server_start_successfully(self): # Assertions assert result.exit_code == 0 + def test_server_handles_no_domain_exception(self): + """Test that the server command gracefully handles NoDomainException""" + with patch( + "protean.cli.derive_domain", + side_effect=NoDomainException("Domain not found"), + ): + args = ["server", "--domain", "invalid_domain.py"] + result = runner.invoke(app, args) + + assert result.exit_code != 0 + assert "Error loading Protean domain: Domain not found" in result.output + assert isinstance(result.exception, SystemExit) + + def test_server_runs_in_test_mode(self): + """Test that the server runs in test mode when the flag is provided""" + change_working_directory_to("test7") + + # Mock the Engine class entirely + with patch("protean.cli.Engine") as MockEngine: + mock_engine_instance = MockEngine.return_value + mock_engine_instance.exit_code = 0 # Set the exit code + + args = ["server", "--domain", "publishing7.py", "--test-mode"] + result = runner.invoke(app, args) + + # Assertions + assert result.exit_code == 0 + mock_engine_instance.run.assert_called_once() # Ensure `run` was called + MockEngine.assert_called_once_with( + ANY, test_mode=True, debug=False + ) # Ensure Engine was instantiated with the correct arguments + + def test_server_runs_in_debug_mode(self): + """Test that the server runs in debug mode and sets the correct logger level""" + change_working_directory_to("test7") + + # Mock the logger used in the Engine class + with patch("protean.server.engine.logger") as mock_logger: + args = ["server", "--domain", "publishing7.py", "--debug"] + result = runner.invoke(app, args) + + assert result.exit_code == 0 + mock_logger.setLevel.assert_called_once_with(logging.DEBUG) + + @pytest.mark.skip(reason="Not implemented") def test_that_server_processes_messages_on_start(self): # Start in non-test mode # Ensure messages are processed # Manually shutdown with `asyncio.create_task(engine.shutdown())` pass - @pytest.mark.skip(reason="Not implemented") - def test_debug_mode(self): - # Test debug mode is saved and correct logger level is set + def test_server_with_max_workers(self): + """Test that the server command handles the MAX_WORKERS input (future implementation)""" + # This is a placeholder for when MAX_WORKERS is implemented as a command-line input pass diff --git a/tests/cli/test_test.py b/tests/cli/test_test.py new file mode 100644 index 00000000..d112116f --- /dev/null +++ b/tests/cli/test_test.py @@ -0,0 +1,177 @@ +from unittest.mock import call + +import pytest +from typer.testing import CliRunner + +from protean.cli import Category, app + +runner = CliRunner() + + +@pytest.fixture +def mock_subprocess_call(mocker): + return mocker.patch("protean.cli.subprocess.call") + + +@pytest.mark.parametrize( + "category,expected_calls", + [ + ( + Category.EVENTSTORE, + [ + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "-m", + "eventstore", + "--store=MEMORY", + ] + ), + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "-m", + "eventstore", + "--store=MESSAGE_DB", + ] + ), + ], + ), + ( + Category.DATABASE, + [ + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "-m", + "database", + "--db=POSTGRESQL", + ] + ), + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "-m", + "database", + "--db=SQLITE", + ] + ), + ], + ), + ( + Category.FULL, + [ + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "--slow", + "--sqlite", + "--postgresql", + "--elasticsearch", + "--redis", + "--message_db", + "--cov=protean", + "--cov-config", + ".coveragerc", + "tests", + ] + ), + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "-m", + "database", + "--db=POSTGRESQL", + ] + ), + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "-m", + "database", + "--db=SQLITE", + ] + ), + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "-m", + "eventstore", + "--store=MESSAGE_DB", + ] + ), + ], + ), + ( + Category.COVERAGE, + [ + call( + [ + "pytest", + "--cache-clear", + "--ignore=tests/support/", + "--slow", + "--sqlite", + "--postgresql", + "--elasticsearch", + "--redis", + "--message_db", + "--cov=protean", + "--cov-config", + ".coveragerc", + "tests", + ] + ), + ], + ), + ( + Category.CORE, + [ + call(["pytest", "--cache-clear", "--ignore=tests/support/"]), + ], + ), + ], +) +def test_command(mock_subprocess_call, category, expected_calls): + result = runner.invoke(app, ["test", "--category", category.value]) + + assert result.exit_code == 0 + assert mock_subprocess_call.call_count == len(expected_calls) + mock_subprocess_call.assert_has_calls(expected_calls, any_order=True) + + +def test_default_category(mock_subprocess_call): + # Test the command with the default category (CORE) + result = runner.invoke(app, ["test"]) + + assert result.exit_code == 0 + mock_subprocess_call.assert_called_once_with( + ["pytest", "--cache-clear", "--ignore=tests/support/"] + ) + + +def test_invalid_category(mock_subprocess_call): + # Test the command with an invalid category (should raise error) + result = runner.invoke(app, ["test", "--category", "INVALID"]) + + assert result.exit_code == 2 + assert ( + "Invalid value for '-c' / '--category': 'INVALID' is not one of " + in result.output + ) diff --git a/tests/server/test_engine_handle_exception.py b/tests/server/test_engine_handle_exception.py new file mode 100644 index 00000000..050865bc --- /dev/null +++ b/tests/server/test_engine_handle_exception.py @@ -0,0 +1,86 @@ +import asyncio +from unittest import mock + +import pytest + +from protean.domain import Domain +from protean.server.engine import Engine + + +@pytest.fixture +def engine(): + domain = Domain(__file__, load_toml=False) + return Engine(domain, test_mode=True, debug=True) + + +def test_handle_exception_with_exception(engine): + loop = engine.loop + + async def faulty_task(): + raise Exception("Test exception") + + with mock.patch.object(engine, "shutdown") as mock_shutdown, mock.patch( + "traceback.print_stack" + ) as mock_print_stack, mock.patch( + "protean.server.engine.logger.error" + ) as mock_logger_error: + # Start the engine in a separate coroutine + async def run_engine(): + loop.create_task(faulty_task()) + engine.run() + + # Run the engine and handle the exception + loop.run_until_complete(run_engine()) + + # Ensure the logger captured the exception message + mock_logger_error.assert_any_call("Caught exception: Test exception") + mock_print_stack.assert_called_once() + mock_shutdown.assert_called_once_with(exit_code=1) + + +def test_handle_exception_without_exception(engine): + loop = engine.loop + + async def faulty_task(): + raise Exception("Test exception without exception in context") + + with mock.patch.object(engine, "shutdown") as mock_shutdown, mock.patch( + "protean.server.engine.logger.error" + ) as mock_logger_error: + # Create a faulty task without an exception in the context + async def run_engine(): + faulty_context = {"message": "Test message"} + loop.call_exception_handler(faulty_context) + engine.run() + + # Run the engine + loop.run_until_complete(run_engine()) + + mock_logger_error.assert_any_call("Caught exception: Test message") + mock_shutdown.assert_not_called() + + +def test_handle_exception_while_running(engine): + loop = engine.loop + + async def faulty_task(): + raise Exception("Test exception while running") + + with mock.patch.object(engine, "shutdown") as mock_shutdown, mock.patch( + "traceback.print_stack" + ) as mock_print_stack, mock.patch( + "protean.server.engine.logger.error" + ) as mock_logger_error: + # Run the engine with a faulty task that raises an exception + async def run_engine(): + loop.create_task(faulty_task()) + engine.run() + + # Run the engine + loop.run_until_complete(run_engine()) + + mock_logger_error.assert_any_call( + "Caught exception: Test exception while running" + ) + mock_print_stack.assert_called_once() + mock_shutdown.assert_called_once_with(exit_code=1)