"""Tests for muxplex/cli.py — CLI entry point.""" import json import os import shutil import stat from pathlib import Path from unittest.mock import MagicMock, patch def test_cli_module_importable(): """muxplex.cli must be importable.""" from muxplex.cli import main # noqa: F401 def test_main_calls_serve_by_default(): """Calling main() with no args must invoke serve() with None defaults (settings layer resolves).""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex"]): main() mock_serve.assert_called_once_with( host=None, port=None, auth=None, session_ttl=None ) def test_main_passes_custom_host_and_port(): """main() with --host/--port must forward them to serve(); unset flags are None.""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex", "--host", "192.168.1.1", "--port", "9000"]): main() mock_serve.assert_called_once_with( host="192.168.1.1", port=9000, auth=None, session_ttl=None ) def test_main_default_host_is_localhost(): """Default --host must be None (settings layer resolves to 127.0.0.1).""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex"]): main() _, kwargs = mock_serve.call_args assert kwargs["host"] is None def test_main_passes_auth_flag(): """main() with --auth password must forward auth='password'; unset flags are None.""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex", "--auth", "password"]): main() mock_serve.assert_called_once_with( host=None, port=None, auth="password", session_ttl=None ) def test_main_passes_session_ttl_flag(): """main() with --session-ttl 3600 must forward session_ttl=3600; unset flags are None.""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex", "--session-ttl", "3600"]): main() mock_serve.assert_called_once_with( host=None, port=None, auth=None, session_ttl=3600 ) def test_show_password_prints_password_from_file(tmp_path, monkeypatch, capsys): """show_password() prints the password when MUXPLEX_AUTH=password and file exists.""" from muxplex.cli import show_password # Set up fake home with password file fake_home = tmp_path / "home" pw_dir = fake_home / ".config" / "muxplex" pw_dir.mkdir(parents=True) pw_file = pw_dir / "password" pw_file.write_text("my-test-password\n") monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setenv("MUXPLEX_AUTH", "password") show_password() captured = capsys.readouterr() assert "my-test-password" in captured.out def test_show_password_no_file(tmp_path, monkeypatch, capsys): """show_password() tells user no file found when in password mode with no file.""" from muxplex.cli import show_password # Set up fake home WITHOUT password file fake_home = tmp_path / "home" fake_home.mkdir(parents=True) monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setenv("MUXPLEX_AUTH", "password") show_password() captured = capsys.readouterr() output_lower = captured.out.lower() assert "no password" in output_lower or "not found" in output_lower def test_show_password_pam_mode(monkeypatch, capsys): """show_password() reports PAM mode when pam_available() is True and not password mode.""" from muxplex.cli import show_password monkeypatch.delenv("MUXPLEX_AUTH", raising=False) with patch("muxplex.cli.pam_available", return_value=True): show_password() captured = capsys.readouterr() assert "pam" in captured.out.lower() def test_reset_secret_writes_new_secret(tmp_path, monkeypatch): """reset_secret() writes a new secret file with content longer than 20 chars.""" from muxplex.cli import reset_secret fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) reset_secret() secret_path = fake_home / ".config" / "muxplex" / "secret" assert secret_path.exists(), "Secret file must be created" content = secret_path.read_text().strip() assert len(content) > 20, f"Secret must be longer than 20 chars, got {len(content)}" def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch): """reset_secret() sets file permissions to 0o600.""" from muxplex.cli import reset_secret fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) reset_secret() secret_path = fake_home / ".config" / "muxplex" / "secret" file_mode = stat.S_IMODE(secret_path.stat().st_mode) assert file_mode == 0o600, f"Expected 0o600, got {oct(file_mode)}" def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): """reset_secret() prints a warning that sessions are now invalid.""" from muxplex.cli import reset_secret fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) reset_secret() captured = capsys.readouterr() output_lower = captured.out.lower() assert "invalid" in output_lower or "warning" in output_lower, ( f"Expected 'invalid' or 'warning' in output, got: {captured.out!r}" ) def test_check_dependencies_exits_when_ttyd_missing(monkeypatch): """_check_dependencies() must sys.exit(1) when ttyd is not in PATH.""" import shutil import pytest from muxplex.cli import _check_dependencies orig_which = shutil.which def fake_which(name): if name == "ttyd": return None return orig_which(name) monkeypatch.setattr(shutil, "which", fake_which) with pytest.raises(SystemExit) as exc_info: _check_dependencies() assert exc_info.value.code == 1 def test_check_dependencies_exits_when_tmux_missing(monkeypatch): """_check_dependencies() must sys.exit(1) when tmux is not in PATH.""" import shutil import pytest from muxplex.cli import _check_dependencies orig_which = shutil.which def fake_which(name): if name == "tmux": return None return orig_which(name) monkeypatch.setattr(shutil, "which", fake_which) with pytest.raises(SystemExit) as exc_info: _check_dependencies() assert exc_info.value.code == 1 def test_check_dependencies_passes_when_all_present(monkeypatch): """_check_dependencies() must not raise when both tmux and ttyd are found.""" import shutil from muxplex.cli import _check_dependencies monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") # Should not raise _check_dependencies() def test_main_check_dependencies_called_for_serve(monkeypatch): """main() must call _check_dependencies() when subcommand is serve.""" from muxplex.cli import main calls = [] monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: calls.append(True)) with patch("muxplex.cli.serve"): with patch("sys.argv", ["muxplex"]): main() assert len(calls) == 1, "_check_dependencies must be called once for serve" def test_dunder_main_calls_main(): """python -m muxplex must call cli.main().""" import importlib.util # Locate __main__.py without executing it (find_spec does not import) spec = importlib.util.find_spec("muxplex.__main__") assert spec is not None and spec.origin is not None with patch("muxplex.cli.main") as mock_main: exec(Path(spec.origin).read_text()) # noqa: S102 mock_main.assert_called_once() # --------------------------------------------------------------------------- # doctor() tests # --------------------------------------------------------------------------- def test_doctor_shows_python_version(capsys): """doctor must show Python version.""" from muxplex.cli import doctor doctor() out = capsys.readouterr().out assert "Python" in out def test_doctor_checks_tmux(capsys, monkeypatch): """doctor must check for tmux.""" import subprocess from muxplex.cli import doctor monkeypatch.setattr( "shutil.which", lambda name: "/usr/bin/tmux" if name == "tmux" else None ) monkeypatch.setattr( subprocess, "run", lambda *a, **kw: type( "R", (), {"returncode": 0, "stdout": "tmux 3.4", "stderr": ""} )(), ) doctor() out = capsys.readouterr().out assert "tmux" in out def test_doctor_reports_missing_ttyd(capsys, monkeypatch): """doctor must report when ttyd is missing.""" from muxplex.cli import doctor original_which = shutil.which def mock_which(name): if name == "ttyd": return None return original_which(name) monkeypatch.setattr("shutil.which", mock_which) doctor() out = capsys.readouterr().out assert "ttyd" in out assert "not found" in out def test_doctor_shows_platform(capsys): """doctor must show platform info.""" from muxplex.cli import doctor doctor() out = capsys.readouterr().out assert "Platform" in out def test_doctor_subcommand_registered(): """doctor must be a valid subcommand in main() argparse.""" import io from muxplex.cli import main buf = io.StringIO() with patch("sys.argv", ["muxplex", "--help"]): try: with patch("sys.stdout", buf): main() except SystemExit: pass help_text = buf.getvalue().lower() assert "doctor" in help_text def test_main_dispatches_to_doctor(monkeypatch): """main() with 'doctor' subcommand must invoke doctor().""" from muxplex.cli import main calls = [] monkeypatch.setattr("muxplex.cli.doctor", lambda: calls.append(True)) with patch("sys.argv", ["muxplex", "doctor"]): main() assert len(calls) == 1, ( "doctor() must be called once when 'doctor' subcommand is used" ) # --------------------------------------------------------------------------- # upgrade / update subcommand tests # --------------------------------------------------------------------------- def test_upgrade_subcommand_registered(): """upgrade must be a valid subcommand.""" import io from muxplex.cli import main buf = io.StringIO() with patch("sys.argv", ["muxplex", "--help"]): try: with patch("sys.stdout", buf): main() except SystemExit: pass help_text = buf.getvalue().lower() assert "upgrade" in help_text def test_update_alias_registered(): """update must be a valid subcommand (alias for upgrade).""" import io from muxplex.cli import main buf = io.StringIO() with patch("sys.argv", ["muxplex", "--help"]): try: with patch("sys.stdout", buf): main() except SystemExit: pass help_text = buf.getvalue().lower() assert "update" in help_text def test_upgrade_calls_uv_tool_install(monkeypatch, capsys): """upgrade must attempt uv tool install when update is available.""" import subprocess import muxplex.cli as cli_mod calls = [] def mock_run(cmd, **kwargs): calls.append(cmd) return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setattr(cli_mod, "doctor", lambda: None) # Mock version check so upgrade proceeds regardless of local install type monkeypatch.setattr( cli_mod, "_check_for_update", lambda info: (True, "update available (abc12345 → def67890)"), ) with patch("muxplex.service.service_install", lambda: None): cli_mod.upgrade() # Should have called uv tool install uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] assert len(uv_calls) > 0, "upgrade must call uv tool install" def test_main_dispatches_to_upgrade(monkeypatch): """main() with 'upgrade' subcommand must invoke upgrade().""" from muxplex.cli import main calls = [] monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True)) with patch("sys.argv", ["muxplex", "upgrade"]): main() assert len(calls) == 1, "upgrade() must be called once for 'upgrade' subcommand" def test_main_dispatches_update_to_upgrade(monkeypatch): """main() with 'update' subcommand must also invoke upgrade().""" from muxplex.cli import main calls = [] monkeypatch.setattr("muxplex.cli.upgrade", lambda force=False: calls.append(True)) with patch("sys.argv", ["muxplex", "update"]): main() assert len(calls) == 1, "upgrade() must be called once for 'update' subcommand" # --------------------------------------------------------------------------- # Smart version-check tests (_get_install_info / _check_for_update) # --------------------------------------------------------------------------- def test_get_install_info_returns_dict(): """_get_install_info must return a dict with all required keys.""" from muxplex.cli import _get_install_info info = _get_install_info() assert "source" in info assert "version" in info assert "commit" in info assert "url" in info assert info["source"] in ("git", "editable", "pypi", "unknown") def test_check_for_update_editable_returns_false(): """Editable installs must never suggest an update.""" from muxplex.cli import _check_for_update info = {"source": "editable", "version": "0.1.0", "commit": None, "url": None} available, msg = _check_for_update(info) assert available is False assert "editable" in msg def test_upgrade_force_skips_version_check(monkeypatch, capsys): """upgrade(force=True) must skip the version check and proceed to install.""" import subprocess import muxplex.cli as cli_mod calls = [] def mock_run(cmd, **kwargs): calls.append(cmd) return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setattr(cli_mod, "doctor", lambda: None) # With force=True the version check must be bypassed entirely check_calls = [] monkeypatch.setattr( cli_mod, "_check_for_update", lambda info: check_calls.append(info) or (True, "should not be reached"), ) with patch("muxplex.service.service_install", lambda: None): cli_mod.upgrade(force=True) # _check_for_update must NOT have been called when force=True assert len(check_calls) == 0, "Version check must be skipped when force=True" # uv install must still be attempted uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] assert len(uv_calls) > 0, "upgrade(force=True) must still call uv tool install" def test_upgrade_already_up_to_date_skips_install(monkeypatch, capsys): """upgrade() must print 'up to date' and NOT call uv when version check says current.""" import subprocess import muxplex.cli as cli_mod calls = [] def mock_run(cmd, **kwargs): calls.append(cmd) return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setattr(cli_mod, "doctor", lambda: None) monkeypatch.setattr( cli_mod, "_check_for_update", lambda info: (False, "up to date (commit abcd1234)"), ) with patch("muxplex.service.service_install", lambda: None): cli_mod.upgrade() out = capsys.readouterr().out assert "up to date" in out.lower() or "already" in out.lower() # uv install must NOT have been called uv_calls = [c for c in calls if isinstance(c, list) and "uv" in str(c)] assert len(uv_calls) == 0, "uv must NOT be called when already up to date" def test_upgrade_force_flag_registered(): """upgrade --force must be accepted by argparse without error.""" import io from muxplex.cli import main buf = io.StringIO() with patch("sys.argv", ["muxplex", "upgrade", "--help"]): try: with patch("sys.stdout", buf): main() except SystemExit: pass help_text = buf.getvalue() assert "--force" in help_text # --------------------------------------------------------------------------- # serve() settings.json integration tests # --------------------------------------------------------------------------- def test_serve_reads_host_from_settings(tmp_path, monkeypatch): """serve(host=None) must use host from settings.json.""" settings_file = tmp_path / "settings.json" settings_file.write_text(json.dumps({"host": "192.168.0.1"})) monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file) calls = [] def fake_run(*args, **kwargs): calls.append(kwargs) with patch("uvicorn.run", fake_run): with patch.dict("sys.modules", {"muxplex.main": MagicMock()}): from muxplex.cli import serve serve(host=None) assert len(calls) == 1 assert calls[0]["host"] == "192.168.0.1" def test_serve_cli_flag_overrides_settings(tmp_path, monkeypatch): """serve(host='10.0.0.1') must override settings.json host.""" settings_file = tmp_path / "settings.json" settings_file.write_text(json.dumps({"host": "192.168.0.1"})) monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file) calls = [] def fake_run(*args, **kwargs): calls.append(kwargs) with patch("uvicorn.run", fake_run): with patch.dict("sys.modules", {"muxplex.main": MagicMock()}): from muxplex.cli import serve serve(host="10.0.0.1") assert len(calls) == 1 assert calls[0]["host"] == "10.0.0.1" def test_serve_falls_back_to_default_when_no_settings_file(tmp_path, monkeypatch): """serve() with no settings file and no CLI flags uses hardcoded defaults.""" settings_file = tmp_path / "nonexistent_settings.json" # Deliberately not written — file does not exist monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file) calls = [] def fake_run(*args, **kwargs): calls.append(kwargs) with patch("uvicorn.run", fake_run): with patch.dict("sys.modules", {"muxplex.main": MagicMock()}): from muxplex.cli import serve serve() assert len(calls) == 1 assert calls[0]["host"] == "127.0.0.1" assert calls[0]["port"] == 8088 def test_serve_port_from_settings(tmp_path, monkeypatch): """serve(port=None) must use port from settings.json.""" settings_file = tmp_path / "settings.json" settings_file.write_text(json.dumps({"port": 9999})) monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file) calls = [] def fake_run(*args, **kwargs): calls.append(kwargs) with patch("uvicorn.run", fake_run): with patch.dict("sys.modules", {"muxplex.main": MagicMock()}): from muxplex.cli import serve serve(port=None) assert len(calls) == 1 assert calls[0]["port"] == 9999 def test_serve_session_ttl_from_settings(tmp_path, monkeypatch): """serve(session_ttl=None) must use session_ttl from settings.json.""" settings_file = tmp_path / "settings.json" settings_file.write_text(json.dumps({"session_ttl": 3600})) monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file) monkeypatch.delenv("MUXPLEX_SESSION_TTL", raising=False) with patch("uvicorn.run"): with patch.dict("sys.modules", {"muxplex.main": MagicMock()}): from muxplex.cli import serve serve(session_ttl=None) assert os.environ.get("MUXPLEX_SESSION_TTL") == "3600" def test_serve_session_ttl_zero_is_valid(tmp_path, monkeypatch): """serve(session_ttl=0) must work — 0 means browser session, a valid value.""" settings_file = tmp_path / "settings.json" settings_file.write_text(json.dumps({"session_ttl": 3600})) monkeypatch.setattr("muxplex.settings.SETTINGS_PATH", settings_file) monkeypatch.delenv("MUXPLEX_SESSION_TTL", raising=False) with patch("uvicorn.run"): with patch.dict("sys.modules", {"muxplex.main": MagicMock()}): from muxplex.cli import serve serve(session_ttl=0) assert os.environ.get("MUXPLEX_SESSION_TTL") == "0" # --------------------------------------------------------------------------- # argparse refactoring tests — None defaults, serve flags on both parsers, # upgrade alias # --------------------------------------------------------------------------- def test_main_passes_none_for_unset_flags(): """main() with no flags passes None for host/port/auth/session_ttl to serve().""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex"]): main() mock_serve.assert_called_once_with( host=None, port=None, auth=None, session_ttl=None ) def test_main_passes_explicit_host_only(): """main() with --host 10.0.0.1 passes host='10.0.0.1', others as None.""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch("sys.argv", ["muxplex", "--host", "10.0.0.1"]): main() mock_serve.assert_called_once_with( host="10.0.0.1", port=None, auth=None, session_ttl=None ) def test_main_serve_subcommand_accepts_flags(): """'muxplex serve --host 10.0.0.1 --port 9000' passes values to serve().""" from muxplex.cli import main with patch("muxplex.cli.serve") as mock_serve: with patch( "sys.argv", ["muxplex", "serve", "--host", "10.0.0.1", "--port", "9000"] ): main() mock_serve.assert_called_once_with( host="10.0.0.1", port=9000, auth=None, session_ttl=None ) def test_help_shows_single_upgrade_line(): """Help output shows 'upgrade (update)' alias notation, not two separate subcommand entries.""" import io from muxplex.cli import main buf = io.StringIO() with patch("sys.argv", ["muxplex", "--help"]): try: with patch("sys.stdout", buf): main() except SystemExit: pass help_text = buf.getvalue() # With aliases=['update'], argparse renders: 'upgrade (update) description' # With separate parsers, 'upgrade' and 'update' each have their own help lines assert "upgrade (update)" in help_text, ( "upgrade and update must appear as alias notation 'upgrade (update)', not two separate entries. " f"Got help text:\n{help_text}" ) def test_doctor_shows_serve_config(tmp_path, monkeypatch, capsys): """doctor() must show the current serve config (host, port, auth).""" import json import muxplex.settings as settings_mod settings_file = tmp_path / "settings.json" settings_file.write_text( json.dumps({"host": "0.0.0.0", "port": 9999, "auth": "password"}) ) monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_file) from muxplex.cli import doctor doctor() out = capsys.readouterr().out assert "0.0.0.0" in out assert "9999" in out assert "password" in out # --------------------------------------------------------------------------- # service subcommand dispatch tests # --------------------------------------------------------------------------- def test_service_install_dispatches(): """muxplex service install must call service_install().""" from muxplex.cli import main with patch("muxplex.service.service_install") as mock_fn: with patch("sys.argv", ["muxplex", "service", "install"]): main() mock_fn.assert_called_once() def test_service_uninstall_dispatches(): """muxplex service uninstall must call service_uninstall().""" from muxplex.cli import main with patch("muxplex.service.service_uninstall") as mock_fn: with patch("sys.argv", ["muxplex", "service", "uninstall"]): main() mock_fn.assert_called_once() def test_service_start_dispatches(): """muxplex service start must call service_start().""" from muxplex.cli import main with patch("muxplex.service.service_start") as mock_fn: with patch("sys.argv", ["muxplex", "service", "start"]): main() mock_fn.assert_called_once() def test_service_stop_dispatches(): """muxplex service stop must call service_stop().""" from muxplex.cli import main with patch("muxplex.service.service_stop") as mock_fn: with patch("sys.argv", ["muxplex", "service", "stop"]): main() mock_fn.assert_called_once() def test_service_restart_dispatches(): """muxplex service restart must call service_restart().""" from muxplex.cli import main with patch("muxplex.service.service_restart") as mock_fn: with patch("sys.argv", ["muxplex", "service", "restart"]): main() mock_fn.assert_called_once() def test_service_status_dispatches(): """muxplex service status must call service_status().""" from muxplex.cli import main with patch("muxplex.service.service_status") as mock_fn: with patch("sys.argv", ["muxplex", "service", "status"]): main() mock_fn.assert_called_once() def test_service_logs_dispatches(): """muxplex service logs must call service_logs().""" from muxplex.cli import main with patch("muxplex.service.service_logs") as mock_fn: with patch("sys.argv", ["muxplex", "service", "logs"]): main() mock_fn.assert_called_once() def test_service_subcommand_in_help(): """'service' must appear in muxplex --help output.""" import io from muxplex.cli import main buf = io.StringIO() with patch("sys.argv", ["muxplex", "--help"]): try: with patch("sys.stdout", buf): main() except SystemExit: pass help_text = buf.getvalue().lower() assert "service" in help_text # --------------------------------------------------------------------------- # task-6: Verify old launchd/systemd helpers removed from cli.py # --------------------------------------------------------------------------- def test_old_install_launchd_removed_from_cli(): """_install_launchd must no longer exist in muxplex.cli (moved to muxplex.service).""" import muxplex.cli as cli_mod assert not hasattr(cli_mod, "_install_launchd"), ( "_install_launchd should be removed from cli.py; functionality is in muxplex.service" ) def test_old_install_systemd_removed_from_cli(): """_install_systemd must no longer exist in muxplex.cli (moved to muxplex.service).""" import muxplex.cli as cli_mod assert not hasattr(cli_mod, "_install_systemd"), ( "_install_systemd should be removed from cli.py; functionality is in muxplex.service" ) # --------------------------------------------------------------------------- # config subcommand tests # --------------------------------------------------------------------------- def test_config_list_shows_all_keys(capsys, tmp_path, monkeypatch): """config list must show all DEFAULT_SETTINGS keys.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_list config_list() out = capsys.readouterr().out for key in settings_mod.DEFAULT_SETTINGS: assert key in out, f"config list must show '{key}'" def test_config_get_returns_value(capsys, tmp_path, monkeypatch): """config get must return the value of a known key.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_get config_get("port") out = capsys.readouterr().out.strip() assert out == "8088" def test_config_get_unknown_key_exits(tmp_path, monkeypatch): """config get with unknown key must exit 1.""" import pytest import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_get with pytest.raises(SystemExit): config_get("nonexistent_key") def test_config_set_persists_value(tmp_path, monkeypatch): """config set must persist the value to settings.json.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_set config_set("host", "0.0.0.0") settings = settings_mod.load_settings() assert settings["host"] == "0.0.0.0" def test_config_set_coerces_int(tmp_path, monkeypatch): """config set must coerce port to int.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_set config_set("port", "9090") settings = settings_mod.load_settings() assert settings["port"] == 9090 def test_config_set_coerces_bool(tmp_path, monkeypatch): """config set must coerce booleans.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_set config_set("window_size_largest", "true") settings = settings_mod.load_settings() assert settings["window_size_largest"] is True def test_config_reset_all(tmp_path, monkeypatch): """config reset (no key) must reset all settings to defaults.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_set, config_reset config_set("host", "0.0.0.0") config_set("port", "9090") config_reset(None) settings = settings_mod.load_settings() assert settings["host"] == "127.0.0.1" assert settings["port"] == 8088 def test_config_reset_single_key(tmp_path, monkeypatch): """config reset must reset only that key.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "s.json") from muxplex.cli import config_set, config_reset config_set("host", "0.0.0.0") config_set("port", "9090") config_reset("host") settings = settings_mod.load_settings() assert settings["host"] == "127.0.0.1" assert settings["port"] == 9090 # unchanged def test_config_subcommand_registered(): """config must appear in --help.""" import subprocess import sys result = subprocess.run( [sys.executable, "-m", "muxplex", "config", "--help"], capture_output=True, text=True, ) assert result.returncode == 0 assert "list" in result.stdout assert "get" in result.stdout assert "set" in result.stdout assert "reset" in result.stdout def test_upgrade_uses_service_module_install(monkeypatch, capsys): """upgrade() must call muxplex.service.service_install.""" import subprocess import muxplex.cli as cli_mod calls = [] def mock_run(cmd, **kwargs): calls.append(cmd) return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setattr(cli_mod, "doctor", lambda: None) monkeypatch.setattr( cli_mod, "_check_for_update", lambda info: (True, "update available (abc12345 \u2192 def67890)"), ) service_install_calls = [] with patch( "muxplex.service.service_install", lambda: service_install_calls.append(True) ): cli_mod.upgrade() assert len(service_install_calls) > 0, ( "upgrade() must call muxplex.service.service_install() to regenerate the service file" )