Files
petrbalvin 3b8ed31f67
Test / test (push) Successful in 1m7s
Release / release (push) Successful in 1m11s
chore: prepare release v0.4.0
2026-07-26 18:15:29 +02:00

513 lines
19 KiB
Python

"""Tests for CLI entry point."""
from __future__ import annotations
import argparse
import contextlib
import json
import sys
import urllib.error
from pathlib import Path
from unittest import mock
import pytest
from volumen import VERSION
from volumen import cli as cli_module
from volumen.cli import main
class TestVersionCommand:
def test_version_output(self, capsys: pytest.CaptureFixture[str]) -> None:
with (
mock.patch.object(sys, "argv", ["volumen", "version"]),
contextlib.suppress(SystemExit),
):
main()
captured = capsys.readouterr()
assert VERSION in captured.out
class TestHelpCommand:
def test_help_output(self, capsys: pytest.CaptureFixture[str]) -> None:
with mock.patch.object(sys, "argv", ["volumen", "--help"]), contextlib.suppress(SystemExit):
main()
captured = capsys.readouterr()
assert "volumen" in captured.out or "usage" in captured.out.lower()
class TestUnknownCommand:
def test_unknown_command_exits(self) -> None:
with mock.patch.object(sys, "argv", ["volumen", "bogus"]), pytest.raises(SystemExit):
main()
class TestParseServeOptions:
def test_parse_serve_defaults(self) -> None:
with mock.patch.object(sys, "argv", ["volumen", "serve"]):
import argparse
parser = argparse.ArgumentParser()
sp = parser.add_subparsers(dest="command")
sp_serve = sp.add_parser("serve")
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
sp_serve.add_argument("--host", default=None)
sp_serve.add_argument("--port", type=int, default=None)
sp_serve.add_argument("--content", default=None)
args = parser.parse_args(["serve"])
assert args.config == "/etc/volumen/config.toml"
def test_parse_serve_overrides(self) -> None:
import argparse
parser = argparse.ArgumentParser()
sp = parser.add_subparsers(dest="command")
sp_serve = sp.add_parser("serve")
sp_serve.add_argument("--config", default="/etc/volumen/config.toml")
sp_serve.add_argument("--host", default=None)
sp_serve.add_argument("--port", type=int, default=None)
sp_serve.add_argument("--content", default=None)
args = parser.parse_args(
["serve", "--config", "/t.toml", "--host", "127.0.0.1", "--port", "9000"]
)
assert args.config == "/t.toml"
assert args.host == "127.0.0.1"
assert args.port == 9000
class TestPrepareConfig:
def test_prepare_config_writes_template(self, tmp_path: Path) -> None:
from volumen.config import Config
path = tmp_path / "config.toml"
result = Config.generate_default(str(path))
assert result == str(path)
assert path.exists()
def test_prepare_config_noops_when_present(self, tmp_path: Path) -> None:
from volumen.config import Config
path = tmp_path / "config.toml"
path.write_text("[server]\nport = 1\n")
result = Config.generate_default(str(path))
assert result is None
class TestCheckUpdate:
def _fake_response(self, payload: dict[str, object]) -> object:
"""Build a context-manager-compatible mock that returns *payload* on .read()."""
response = mock.MagicMock()
response.read.return_value = json.dumps(payload).encode("utf-8")
response.__enter__.return_value = response
return response
def _patches(
self,
*,
installed: str,
latest: str | None,
error: Exception | None = None,
) -> object:
"""Stack of context managers patching version + urlopen for one call."""
from contextlib import ExitStack
stack = ExitStack()
stack.enter_context(
mock.patch.object(cli_module, "_get_installed_version", return_value=installed)
)
if error is not None:
stack.enter_context(
mock.patch.object(cli_module.urllib.request, "urlopen", side_effect=error)
)
else:
stack.enter_context(
mock.patch.object(
cli_module.urllib.request,
"urlopen",
return_value=self._fake_response({"info": {"version": latest}}),
)
)
return stack
def test_update_available(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(installed="0.4.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 1
assert "0.4.0" in captured.out
assert "0.5.0" in captured.out
assert "uv tool upgrade volumen" in captured.out
def test_up_to_date(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(installed="0.5.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 0
assert "up to date" in captured.out
def test_local_ahead_of_remote(self, capsys: pytest.CaptureFixture[str]) -> None:
"""A locally-built unreleased version is not 'behind' the latest tag."""
args = argparse.Namespace(json=False)
with self._patches(installed="1.0.0a1", latest="0.9.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 0
assert "up to date" in captured.out
def test_prerelease_handling(self) -> None:
"""Pre-release and local segments are stripped before numeric comparison.
Implementation is intentionally conservative — ``0.5.0a1`` and ``0.5.0``
are seen as the same core version (``(0, 5, 0)``), so we report no
update between them. A higher minor/major with a pre-release or local
segment is still correctly detected.
"""
assert cli_module._compare_versions("0.5.0", "0.5.1a1+build") is True
assert cli_module._compare_versions("0.5.0+a", "0.5.0+a") is False
assert cli_module._compare_versions("0.5.0+a", "0.5.1") is True
def test_offline_returns_exit_code_2(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=False)
with self._patches(
installed="0.4.0",
latest=None,
error=urllib.error.URLError("no internet"),
):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 2
assert "could not reach" in captured.err
assert "0.4.0" in captured.err
def test_json_output_with_update_available(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=True)
with self._patches(installed="0.4.0", latest="0.5.0"):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 1
doc = json.loads(captured.out)
assert doc["current_version"] == "0.4.0"
assert doc["latest_version"] == "0.5.0"
assert doc["update_available"] is True
assert "error" not in doc
def test_json_output_when_offline(self, capsys: pytest.CaptureFixture[str]) -> None:
args = argparse.Namespace(json=True)
with self._patches(
installed="0.4.0",
latest=None,
error=urllib.error.URLError("dns failure"),
):
rc = cli_module._check_update(args)
captured = capsys.readouterr()
assert rc == 2
doc = json.loads(captured.out)
assert doc["current_version"] == "0.4.0"
assert doc["latest_version"] is None
assert doc["update_available"] is False
assert "error" in doc
class TestServeCommand:
def _config_path(self, tmp_path: Path, content_dir: Path) -> Path:
"""Write a minimal but complete config and return the path."""
config_path = tmp_path / "config.toml"
users_file = tmp_path / "users.toml"
config_path.write_text(
f'content_dir = "{content_dir}"\n'
f'users_file = "{users_file}"\n'
"[admin]\n"
'password_hash = ""\n'
)
return config_path
def test_serve_loads_config_and_invokes_uvicorn(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
content_dir = tmp_path / "posts"
content_dir.mkdir()
config_path = self._config_path(tmp_path, content_dir)
import uvicorn
captured: list[dict[str, object]] = []
def fake_run(app: object, **kwargs: object) -> None:
captured.append({"app": app, **kwargs})
monkeypatch.setattr(uvicorn, "run", fake_run)
args = argparse.Namespace(
config=str(config_path),
host=None,
port=None,
content=None,
)
cli_module._serve(args)
assert len(captured) == 1
call = captured[0]
assert call["host"] == "::"
assert call["port"] == 9090
assert call["log_level"] == "info"
from fastapi import FastAPI
assert isinstance(call["app"], FastAPI)
def test_serve_applies_host_and_port_overrides(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
content_dir = tmp_path / "posts"
content_dir.mkdir()
config_path = self._config_path(tmp_path, content_dir)
import uvicorn
captured: list[dict[str, object]] = []
def fake_run(app: object, **kwargs: object) -> None:
captured.append(kwargs)
monkeypatch.setattr(uvicorn, "run", fake_run)
args = argparse.Namespace(
config=str(config_path),
host="127.0.0.1",
port=9000,
content=None,
)
cli_module._serve(args)
assert captured[0]["host"] == "127.0.0.1"
assert captured[0]["port"] == 9000
class TestStatusCommand:
def test_status_human_output_with_issues(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
info = {
"config_path": "/etc/volumen/config.toml",
"data_dir": "/var/lib/volumen/posts",
"users_file": "/var/lib/volumen/users.toml",
"config_exists": True,
"data_dir_exists": True,
"users_file_exists": False,
"posts_subdir_exists": True,
"media_subdir_exists": True,
"password_hash_set": True,
"session_key_ok": True,
"systemd_unit_installed": True,
"service_active": "inactive",
"admin_user_present": False,
"issues": ["users file does not exist", "admin user missing"],
}
monkeypatch.setattr(cli_module, "_status", _FakeStatus(info))
with (
mock.patch.object(sys, "argv", ["volumen", "status"]),
pytest.raises(SystemExit) as exc,
):
main()
captured = capsys.readouterr()
assert exc.value.code == 1
assert "==>" in captured.out
assert "volumen status" in captured.out
assert "users file does not exist" in captured.out
assert "admin user missing" in captured.out
assert "Issues:" in captured.out
def test_status_returns_zero_when_no_issues(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
info = {
"config_path": "/etc/volumen/config.toml",
"data_dir": "/var/lib/volumen/posts",
"users_file": "/var/lib/volumen/users.toml",
"config_exists": True,
"data_dir_exists": True,
"users_file_exists": True,
"posts_subdir_exists": True,
"media_subdir_exists": True,
"password_hash_set": True,
"session_key_ok": True,
"systemd_unit_installed": True,
"service_active": "active",
"admin_user_present": True,
"issues": [],
}
monkeypatch.setattr(cli_module, "_status", _FakeStatus(info))
with (
mock.patch.object(sys, "argv", ["volumen", "status"]),
pytest.raises(SystemExit) as exc,
):
main()
captured = capsys.readouterr()
assert exc.value.code == 0
assert "All checks passed." in captured.out
class _FakeStatus:
"""Mock returning a static info dict from cli._status."""
def __init__(self, info: dict[str, object]) -> None:
self._info = info
def __call__(self, args: argparse.Namespace) -> int:
# Mirror the shape of the real _status(). For human mode print plain
# text; for --json print the info as JSON. Exit code 0 if no issues.
import json as _json
if args.json:
print(_json.dumps(self._info, indent=2, sort_keys=True))
else:
print("==> volumen status")
print()
print(f" Config: {self._info['config_path']}")
print(f" Data dir: {self._info['data_dir']}")
print(f" Users file: {self._info['users_file']}")
unit = "installed" if self._info["systemd_unit_installed"] else "not installed"
print(f" Systemd unit: {unit}")
print(f" Service: {self._info['service_active']}")
presence = "present" if self._info["admin_user_present"] else "missing"
print(f" Admin user: {presence}")
print()
issues = list(self._info["issues"])
if not issues:
print("All checks passed.")
else:
print("Issues:")
for issue in issues:
print(f" - {issue}")
return 0 if not self._info["issues"] else 1
class TestInitCommand:
def test_init_rejects_systemd_under_local(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# Force ``use_user=True`` then ``--systemd`` → exit 2 with explanation.
monkeypatch.setattr(cli_module, "_is_root", lambda: False)
with (
mock.patch.object(
sys,
"argv",
["volumen", "init", "--local", "--systemd"],
),
pytest.raises(SystemExit) as exc,
):
main()
assert exc.value.code == 2
captured = capsys.readouterr()
assert "--systemd is not available for per-user installs" in captured.err
def test_init_propagates_installer_error(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# ``system_install`` is mocked to raise InstallerError → exit 1 + stderr msg.
monkeypatch.setattr(cli_module, "_is_root", lambda: True)
def fake_system_install(**_kwargs: object) -> object:
from volumen.installer import InstallerError
raise InstallerError("simulated installer failure")
# _init does ``from .installer import system_install`` per call, so we
# patch at the import site (volumen.installer.system_install) rather
# than the local reference inside cli_module.
monkeypatch.setattr("volumen.installer.system_install", fake_system_install)
args = argparse.Namespace(
local=False,
systemd=False,
config=None,
data=None,
users_file=None,
service_user="volumen",
enable=True,
admin_password="some-strong-pw-1234",
force=False,
)
with pytest.raises(SystemExit) as exc:
cli_module._init(args)
captured = capsys.readouterr()
assert exc.value.code == 1
assert "simulated installer failure" in captured.err
def test_init_user_install_branch(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# ``--local`` → user_install() path. Mock user_install to skip side effects.
monkeypatch.setattr(cli_module, "_is_root", lambda: False)
from volumen.installer import InstallResult
fake_result = InstallResult(
config_path=Path("/home/u/.config/volumen/config.toml"),
content_dir=Path("/home/u/.local/share/volumen/posts"),
users_file=Path("/home/u/.local/share/volumen/users.toml"),
service_user=None,
password_hash="scrypt$...",
session_key="x" * 128,
systemd_unit_path=None,
messages=[],
)
monkeypatch.setattr(
"volumen.installer.user_install",
lambda **_kw: fake_result,
)
args = argparse.Namespace(
local=True,
systemd=False,
config=None,
data=None,
users_file=None,
service_user="volumen",
enable=True,
admin_password="some-strong-pw-1234",
force=False,
)
cli_module._init(args) # Should not raise.
captured = capsys.readouterr()
assert "==> volumen installed." in captured.out
assert "Service: not installed" in captured.out
class TestPrintInitSummary:
def test_summary_skipped_message(self, capsys: pytest.CaptureFixture[str]) -> None:
from volumen.installer import InstallResult
result = InstallResult(
config_path=Path("/etc/volumen/config.toml"),
content_dir=Path("/var/lib/volumen/posts"),
users_file=Path("/var/lib/volumen/users.toml"),
service_user="volumen",
password_hash="",
session_key="",
systemd_unit_path=None,
messages=["config exists, leaving untouched (use --force to overwrite)"],
)
cli_module._print_init_summary(result, install_service=True, use_user=False)
captured = capsys.readouterr()
assert "left untouched" in captured.out
assert "Nothing to do" in captured.out
def test_summary_user_install(self, capsys: pytest.CaptureFixture[str]) -> None:
from volumen.installer import InstallResult
result = InstallResult(
config_path=Path("/home/u/.config/volumen/config.toml"),
content_dir=Path("/home/u/.local/share/volumen/posts"),
users_file=Path("/home/u/.local/share/volumen/users.toml"),
service_user=None,
password_hash="",
session_key="",
systemd_unit_path=None,
messages=[],
)
cli_module._print_init_summary(result, install_service=False, use_user=True)
captured = capsys.readouterr()
assert "Service: not installed" in captured.out