diff --git a/osism/main.py b/osism/main.py index 82d26c23..ed072eb5 100644 --- a/osism/main.py +++ b/osism/main.py @@ -34,5 +34,5 @@ def main(argv=sys.argv[1:]): return result -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover sys.exit(main(sys.argv[1:])) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 00000000..cb62c681 --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for ``osism/main.py`` — the ``osism`` console-script entry point. + +Two units are covered: + +- ``OsismApp.__init__`` wires loguru's stderr sink and the cliff ``App`` (the + ``osism.commands`` entry-point namespace, deferred help, parser description, + and the ``--version`` action). Every test that constructs ``OsismApp`` + requests the ``mock_logger`` fixture so the process-global loguru + configuration is patched *before* construction and never mutated for real. +- ``main`` constructs the app once, forwards ``argv`` unchanged, and returns + the app's exit code. + +``main``'s ``argv=sys.argv[1:]`` default is evaluated once at import time, so +the default is frozen and reflects the arguments pytest itself was invoked +with. Every test therefore passes ``argv`` explicitly. The ``main`` tests run +only against a mocked ``OsismApp``; the real ``app.run([])`` is never called, +as cliff would drop into its interactive shell on an empty argv. +""" + +import sys + +import pytest + +from osism.main import OsismApp, main, __version__ + + +@pytest.fixture +def mock_logger(mocker): + """Patch the loguru ``logger`` imported by ``osism.main``. + + Returns the mock so tests can assert the sink configuration without + removing the loguru handlers other tests rely on. + """ + return mocker.patch("osism.main.logger") + + +# --------------------------------------------------------------------------- +# OsismApp.__init__ — logging setup +# --------------------------------------------------------------------------- + + +def test_init_removes_default_loguru_handler(mock_logger): + OsismApp() + mock_logger.remove.assert_called_once_with() + + +def test_init_adds_stderr_sink_with_expected_config(mock_logger): + OsismApp() + + mock_logger.add.assert_called_once() + call_args = mock_logger.add.call_args + assert call_args.args[0] is sys.stderr + assert call_args.kwargs["level"] == "INFO" + assert call_args.kwargs["colorize"] is True + assert call_args.kwargs["format"].startswith( + "{time:YYYY-MM-DD HH:mm:ss}" + ) + + +# --------------------------------------------------------------------------- +# OsismApp.__init__ — cliff wiring +# --------------------------------------------------------------------------- + + +def test_init_wires_osism_commands_namespace(mock_logger): + assert OsismApp().command_manager.namespace == "osism.commands" + + +def test_init_enables_deferred_help(mock_logger): + assert OsismApp().deferred_help is True + + +def test_init_sets_parser_description(mock_logger): + assert OsismApp().parser.description == "OSISM manager interface" + + +def test_init_succeeds_when_version_is_none(mock_logger, mocker): + # osism.__version__ (pbr) is None when package metadata is unavailable + # (osism/__init__.py fallback); construction must still succeed. + mocker.patch("osism.main.__version__", None) + assert isinstance(OsismApp(), OsismApp) + + +def test_version_option_exits_zero(mock_logger, capsys): + app = OsismApp() + with pytest.raises(SystemExit) as excinfo: + app.run(["--version"]) + assert excinfo.value.code == 0 + # cliff renders the version line as " "; the prog name comes + # from sys.argv[0] (so it is "pytest" here, "osism" only via the console + # script). Assert on the package version that main.py wires in, which is + # what this entry point actually controls. + assert str(__version__) in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# main — construct once, forward argv, return the app's exit code +# --------------------------------------------------------------------------- + + +def test_main_constructs_app_once_and_forwards_argv(mocker): + mock_app_cls = mocker.patch("osism.main.OsismApp") + + main(["reconciler", "sync"]) + + mock_app_cls.assert_called_once_with() + mock_app_cls.return_value.run.assert_called_once_with(["reconciler", "sync"]) + + +@pytest.mark.parametrize("rc", [42, 0]) +def test_main_returns_app_run_result(mocker, rc): + mock_app_cls = mocker.patch("osism.main.OsismApp") + mock_app_cls.return_value.run.return_value = rc + + assert main(["reconciler", "sync"]) == rc + + +def test_main_forwards_empty_argv(mocker): + # Only the mocked app sees the empty argv — never the real OsismApp, which + # would enter cliff's interactive shell on an empty argument list. + mock_app_cls = mocker.patch("osism.main.OsismApp") + + main([]) + + mock_app_cls.return_value.run.assert_called_once_with([])