mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-02 16:01:16 +02:00
9f79ceac14
Add 39 new tests across 7 files to improve patch coverage: - test_messages: sort desc/asc branches, channel visibility edge cases - test_channels: operator role visibility filtering - test_dashboard: tag name resolution, sender names, operator visibility - test_config: feature dependency auto-disable rules (dashboard, map, members) - test_letsmesh_decoder: reload_keys, _enrich_payload_decoded, guards - test_cli: channel list/add/remove/enable/disable, _import_channels, seed command with channels.yaml Fix ResourceWarning in channel CLI commands by moving db.dispose() into try/finally blocks to ensure sessions close before engine disposal.
459 lines
15 KiB
Python
459 lines
15 KiB
Python
"""Tests for collector CLI commands."""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from meshcore_hub.collector.cli import _import_channels, collector
|
|
from meshcore_hub.common.database import DatabaseManager
|
|
from meshcore_hub.common.models.channel import Channel
|
|
|
|
|
|
def _make_mock_settings(db_url: str, seed_home: str = "/tmp/seed") -> MagicMock:
|
|
mock_settings = MagicMock(
|
|
data_home="/tmp/data",
|
|
effective_seed_home=seed_home,
|
|
effective_database_url=db_url,
|
|
node_tags_file=f"{seed_home}/node_tags.yaml",
|
|
channels_file=f"{seed_home}/channels.yaml",
|
|
)
|
|
mock_settings.model_copy.return_value = mock_settings
|
|
return mock_settings
|
|
|
|
|
|
def _invoke_channel_cmd(runner: CliRunner, db_url: str, args: list[str]):
|
|
mock_settings = _make_mock_settings(db_url)
|
|
with patch(
|
|
"meshcore_hub.common.config.get_collector_settings",
|
|
return_value=mock_settings,
|
|
):
|
|
return runner.invoke(
|
|
collector,
|
|
["--database-url", db_url] + args,
|
|
catch_exceptions=False,
|
|
)
|
|
|
|
|
|
class TestCollectorGroup:
|
|
"""Tests for the collector group command."""
|
|
|
|
def test_collector_without_subcommand_calls_run_service(self):
|
|
runner = CliRunner()
|
|
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
|
|
|
|
with (
|
|
patch(
|
|
"meshcore_hub.common.config.get_collector_settings",
|
|
return_value=mock_settings,
|
|
),
|
|
patch("meshcore_hub.collector.cli._run_collector_service") as mock_run,
|
|
):
|
|
result = runner.invoke(
|
|
collector, ["--mqtt-host", "testhost"], catch_exceptions=False
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_run.assert_called_once()
|
|
|
|
def test_collector_with_data_home_override(self):
|
|
runner = CliRunner()
|
|
mock_settings = _make_mock_settings("sqlite:///default/db")
|
|
|
|
with (
|
|
patch(
|
|
"meshcore_hub.common.config.get_collector_settings",
|
|
return_value=mock_settings,
|
|
),
|
|
patch("meshcore_hub.collector.cli._run_collector_service"),
|
|
):
|
|
result = runner.invoke(
|
|
collector,
|
|
["--data-home", "/custom/data"],
|
|
catch_exceptions=False,
|
|
env={"SEED_HOME": None},
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
mock_settings.model_copy.assert_called_once_with(
|
|
update={"data_home": "/custom/data"}
|
|
)
|
|
|
|
|
|
class TestCollectorRunSubcommand:
|
|
"""Tests for the 'collector run' subcommand."""
|
|
|
|
def test_run_subcommand_calls_run_service(self):
|
|
runner = CliRunner()
|
|
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
|
|
|
|
with (
|
|
patch(
|
|
"meshcore_hub.common.config.get_collector_settings",
|
|
return_value=mock_settings,
|
|
),
|
|
patch("meshcore_hub.collector.cli._run_collector_service") as mock_run,
|
|
):
|
|
result = runner.invoke(collector, ["run"], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 0
|
|
mock_run.assert_called_once()
|
|
|
|
|
|
class TestCollectorSeedSubcommand:
|
|
"""Tests for the 'collector seed' subcommand."""
|
|
|
|
def test_seed_command_help(self):
|
|
runner = CliRunner()
|
|
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
|
|
|
|
with patch(
|
|
"meshcore_hub.common.config.get_collector_settings",
|
|
return_value=mock_settings,
|
|
):
|
|
result = runner.invoke(collector, ["seed", "--help"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "seed" in result.output.lower() or "import" in result.output.lower()
|
|
|
|
|
|
class TestChannelCommands:
|
|
"""Integration tests for channel CLI commands using real SQLite."""
|
|
|
|
@pytest.fixture
|
|
def cli_db_url(self, tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db_path}"
|
|
db = DatabaseManager(db_url)
|
|
db.create_tables()
|
|
db.dispose()
|
|
return db_url
|
|
|
|
def test_channel_list_empty(self, cli_db_url):
|
|
runner = CliRunner()
|
|
result = _invoke_channel_cmd(runner, cli_db_url, ["channel", "list"])
|
|
assert result.exit_code == 0
|
|
assert "No channels found." in result.output
|
|
|
|
def test_channel_add_success(self, cli_db_url):
|
|
runner = CliRunner()
|
|
result = _invoke_channel_cmd(
|
|
runner,
|
|
cli_db_url,
|
|
["channel", "add", "--name", "TestCh", "--key", "AABB" * 8],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "TestCh" in result.output
|
|
assert "added" in result.output
|
|
|
|
result = _invoke_channel_cmd(runner, cli_db_url, ["channel", "list"])
|
|
assert "TestCh" in result.output
|
|
|
|
def test_channel_add_duplicate_name(self, cli_db_url):
|
|
runner = CliRunner()
|
|
_invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "add", "--name", "Dup", "--key", "A" * 32]
|
|
)
|
|
result = _invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "add", "--name", "Dup", "--key", "B" * 32]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "already exists" in result.output
|
|
|
|
def test_channel_add_custom_visibility(self, cli_db_url):
|
|
runner = CliRunner()
|
|
result = _invoke_channel_cmd(
|
|
runner,
|
|
cli_db_url,
|
|
[
|
|
"channel",
|
|
"add",
|
|
"--name",
|
|
"PrivateCh",
|
|
"--key",
|
|
"C" * 32,
|
|
"--visibility",
|
|
"member",
|
|
],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "added" in result.output
|
|
|
|
db = DatabaseManager(cli_db_url)
|
|
with db.session_scope() as session:
|
|
ch = session.query(Channel).filter(Channel.name == "PrivateCh").first()
|
|
assert ch is not None
|
|
assert ch.visibility == "member"
|
|
db.dispose()
|
|
|
|
def test_channel_list_with_data(self, cli_db_url):
|
|
runner = CliRunner()
|
|
_invoke_channel_cmd(
|
|
runner,
|
|
cli_db_url,
|
|
["channel", "add", "--name", "Alpha", "--key", "D" * 32],
|
|
)
|
|
result = _invoke_channel_cmd(runner, cli_db_url, ["channel", "list"])
|
|
assert result.exit_code == 0
|
|
assert "Alpha" in result.output
|
|
assert "Yes" in result.output
|
|
|
|
def test_channel_remove_success(self, cli_db_url):
|
|
runner = CliRunner()
|
|
_invoke_channel_cmd(
|
|
runner,
|
|
cli_db_url,
|
|
["channel", "add", "--name", "Gone", "--key", "E" * 32],
|
|
)
|
|
result = _invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "remove", "--name", "Gone"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "removed" in result.output
|
|
|
|
result = _invoke_channel_cmd(runner, cli_db_url, ["channel", "list"])
|
|
assert "Gone" not in result.output
|
|
|
|
def test_channel_remove_not_found(self, cli_db_url):
|
|
runner = CliRunner()
|
|
result = _invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "remove", "--name", "Missing"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "not found" in result.output
|
|
|
|
def test_channel_disable_then_enable(self, cli_db_url):
|
|
runner = CliRunner()
|
|
_invoke_channel_cmd(
|
|
runner,
|
|
cli_db_url,
|
|
["channel", "add", "--name", "Toggle", "--key", "F" * 32],
|
|
)
|
|
|
|
result = _invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "disable", "--name", "Toggle"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "disabled" in result.output
|
|
|
|
db = DatabaseManager(cli_db_url)
|
|
with db.session_scope() as session:
|
|
ch = session.query(Channel).filter(Channel.name == "Toggle").first()
|
|
assert ch is not None
|
|
assert ch.enabled is False
|
|
db.dispose()
|
|
|
|
result = _invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "enable", "--name", "Toggle"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "enabled" in result.output
|
|
|
|
db = DatabaseManager(cli_db_url)
|
|
with db.session_scope() as session:
|
|
ch = session.query(Channel).filter(Channel.name == "Toggle").first()
|
|
assert ch is not None
|
|
assert ch.enabled is True
|
|
db.dispose()
|
|
|
|
def test_channel_enable_not_found(self, cli_db_url):
|
|
runner = CliRunner()
|
|
result = _invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "enable", "--name", "Missing"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "not found" in result.output
|
|
|
|
def test_channel_disable_not_found(self, cli_db_url):
|
|
runner = CliRunner()
|
|
result = _invoke_channel_cmd(
|
|
runner, cli_db_url, ["channel", "disable", "--name", "Missing"]
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "not found" in result.output
|
|
|
|
|
|
class TestImportChannels:
|
|
"""Unit tests for _import_channels YAML import function."""
|
|
|
|
@pytest.fixture
|
|
def import_db(self, tmp_path):
|
|
db_path = tmp_path / "import.db"
|
|
db = DatabaseManager(f"sqlite:///{db_path}")
|
|
db.create_tables()
|
|
yield db
|
|
db.dispose()
|
|
|
|
def _write_yaml(self, tmp_path, content: str) -> str:
|
|
yaml_file = tmp_path / "channels.yaml"
|
|
yaml_file.write_text(content)
|
|
return str(yaml_file)
|
|
|
|
def test_import_shorthand_format(self, import_db, tmp_path):
|
|
key = "AABBCCDDEEFF00112233445566778899"
|
|
path = self._write_yaml(tmp_path, f"TestCh: {key}\n")
|
|
result = _import_channels(path, import_db)
|
|
|
|
assert result["created"] == 1
|
|
assert result["updated"] == 0
|
|
assert result["errors"] == []
|
|
|
|
with import_db.session_scope() as session:
|
|
ch = session.query(Channel).filter(Channel.name == "TestCh").first()
|
|
assert ch is not None
|
|
assert ch.key_hex == key.upper()
|
|
assert ch.visibility == "community"
|
|
assert ch.enabled is True
|
|
|
|
def test_import_expanded_format(self, import_db, tmp_path):
|
|
key = "11223344556677889900AABBCCDDEEFF"
|
|
path = self._write_yaml(tmp_path, f"Expanded: {{key: {key}, enabled: false}}\n")
|
|
result = _import_channels(path, import_db)
|
|
|
|
assert result["created"] == 1
|
|
assert result["updated"] == 0
|
|
|
|
with import_db.session_scope() as session:
|
|
ch = session.query(Channel).filter(Channel.name == "Expanded").first()
|
|
assert ch is not None
|
|
assert ch.enabled is False
|
|
|
|
def test_import_updates_existing(self, import_db, tmp_path):
|
|
old_key = "A" * 32
|
|
new_key = "B" * 32
|
|
path1 = self._write_yaml(tmp_path, f"MyCh: {old_key}\n")
|
|
_import_channels(path1, import_db)
|
|
|
|
path2 = self._write_yaml(tmp_path, f"MyCh: {new_key}\n")
|
|
result = _import_channels(path2, import_db)
|
|
|
|
assert result["created"] == 0
|
|
assert result["updated"] == 1
|
|
|
|
with import_db.session_scope() as session:
|
|
ch = session.query(Channel).filter(Channel.name == "MyCh").first()
|
|
assert ch.key_hex == new_key.upper()
|
|
assert ch.channel_hash == Channel.compute_channel_hash(new_key.upper())
|
|
|
|
def test_import_empty_yaml(self, import_db, tmp_path):
|
|
path = self._write_yaml(tmp_path, "")
|
|
result = _import_channels(path, import_db)
|
|
|
|
assert result["created"] == 0
|
|
assert result["updated"] == 0
|
|
errors: list[str] = result["errors"] # type: ignore[assignment]
|
|
assert errors == []
|
|
|
|
def test_import_invalid_format(self, import_db, tmp_path):
|
|
path = self._write_yaml(tmp_path, "BadCh: 12345\n")
|
|
result = _import_channels(path, import_db)
|
|
|
|
assert result["created"] == 0
|
|
errors: list[str] = result["errors"] # type: ignore[assignment]
|
|
assert len(errors) == 1
|
|
assert "Invalid format" in errors[0]
|
|
|
|
def test_import_empty_key(self, import_db, tmp_path):
|
|
path = self._write_yaml(tmp_path, "EmptyKey: {key: ''}\n")
|
|
result = _import_channels(path, import_db)
|
|
|
|
assert result["created"] == 0
|
|
errors: list[str] = result["errors"] # type: ignore[assignment]
|
|
assert len(errors) == 1
|
|
assert "Empty key" in errors[0]
|
|
|
|
def test_import_exception_handling(self, import_db, tmp_path):
|
|
path = self._write_yaml(tmp_path, "Boom: AABBCCDDEEFF00112233445566778899\n")
|
|
with patch(
|
|
"meshcore_hub.common.models.channel.Channel",
|
|
side_effect=RuntimeError("db boom"),
|
|
):
|
|
result = _import_channels(path, import_db)
|
|
|
|
errors: list[str] = result["errors"] # type: ignore[assignment]
|
|
assert len(errors) == 1
|
|
assert "Boom" in errors[0]
|
|
|
|
def test_import_multiple_channels(self, import_db, tmp_path):
|
|
path = self._write_yaml(
|
|
tmp_path,
|
|
"Ch1: AABBCCDDEEFF00112233445566778899\n"
|
|
"Ch2: 11223344556677889900AABBCCDDEEFF\n",
|
|
)
|
|
result = _import_channels(path, import_db)
|
|
|
|
assert result["created"] == 2
|
|
assert result["updated"] == 0
|
|
|
|
|
|
class TestChannelSeedImport:
|
|
"""Integration tests for seed command with channels.yaml."""
|
|
|
|
def test_seed_imports_channels_yaml(self, tmp_path):
|
|
runner = CliRunner()
|
|
seed_dir = tmp_path / "seed"
|
|
seed_dir.mkdir()
|
|
(seed_dir / "channels.yaml").write_text(
|
|
"SeededCh: AABBCCDDEEFF00112233445566778899\n"
|
|
)
|
|
|
|
db_path = tmp_path / "seed_test.db"
|
|
db_url = f"sqlite:///{db_path}"
|
|
db = DatabaseManager(db_url)
|
|
db.create_tables()
|
|
db.dispose()
|
|
|
|
mock_settings = _make_mock_settings(db_url, seed_home=str(seed_dir))
|
|
|
|
with patch(
|
|
"meshcore_hub.common.config.get_collector_settings",
|
|
return_value=mock_settings,
|
|
):
|
|
result = runner.invoke(
|
|
collector,
|
|
["--database-url", db_url, "--seed-home", str(seed_dir), "seed"],
|
|
catch_exceptions=False,
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "Channels: 1 created" in result.output
|
|
|
|
db = DatabaseManager(db_url)
|
|
with db.session_scope() as session:
|
|
ch = session.query(Channel).filter(Channel.name == "SeededCh").first()
|
|
assert ch is not None
|
|
assert ch.visibility == "community"
|
|
db.dispose()
|
|
|
|
def test_seed_no_seed_files(self, tmp_path):
|
|
runner = CliRunner()
|
|
empty_seed = tmp_path / "empty_seed"
|
|
empty_seed.mkdir()
|
|
|
|
db_path = tmp_path / "noseed.db"
|
|
db_url = f"sqlite:///{db_path}"
|
|
db = DatabaseManager(db_url)
|
|
db.create_tables()
|
|
db.dispose()
|
|
|
|
mock_settings = _make_mock_settings(db_url, seed_home=str(empty_seed))
|
|
|
|
with patch(
|
|
"meshcore_hub.common.config.get_collector_settings",
|
|
return_value=mock_settings,
|
|
):
|
|
result = runner.invoke(
|
|
collector,
|
|
[
|
|
"--database-url",
|
|
db_url,
|
|
"--seed-home",
|
|
str(empty_seed),
|
|
"seed",
|
|
],
|
|
catch_exceptions=False,
|
|
)
|
|
|
|
assert result.exit_code == 0
|
|
assert "No seed files found" in result.output
|