Add test coverage for channels feature and fix CLI ResourceWarning

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.
This commit is contained in:
Louis King
2026-06-04 14:37:26 +01:00
parent f8c2a7bb40
commit 9f79ceac14
7 changed files with 816 additions and 86 deletions
+63 -56
View File
@@ -324,21 +324,24 @@ def channel_list_cmd(ctx: click.Context) -> None:
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channels = session.query(Channel).order_by(Channel.name).all()
if not channels:
click.echo("No channels found.")
else:
click.echo(
f"{'Name':<20} {'Key':<16} {'Hash':<6} {'Visibility':<12} {'Enabled'}"
)
click.echo("-" * 70)
for ch in channels:
try:
with db.session_scope() as session:
channels = session.query(Channel).order_by(Channel.name).all()
if not channels:
click.echo("No channels found.")
else:
click.echo(
f"{ch.name:<20} {ch.masked_key:<16} {ch.channel_hash:<6} "
f"{ch.visibility:<12} {'Yes' if ch.enabled else 'No'}"
f"{'Name':<20} {'Key':<16} {'Hash':<6} "
f"{'Visibility':<12} {'Enabled'}"
)
db.dispose()
click.echo("-" * 70)
for ch in channels:
click.echo(
f"{ch.name:<20} {ch.masked_key:<16} {ch.channel_hash:<6} "
f"{ch.visibility:<12} {'Yes' if ch.enabled else 'No'}"
)
finally:
db.dispose()
@channel_group.command("add")
@@ -366,23 +369,24 @@ def channel_add_cmd(
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
existing = session.query(Channel).filter(Channel.name == name).first()
if existing:
click.echo(f"Error: Channel '{name}' already exists.", err=True)
db.dispose()
return
try:
with db.session_scope() as session:
existing = session.query(Channel).filter(Channel.name == name).first()
if existing:
click.echo(f"Error: Channel '{name}' already exists.", err=True)
return
channel = Channel(
name=name,
key_hex=key_hex.upper(),
channel_hash=Channel.compute_channel_hash(key_hex.upper()),
visibility=visibility,
enabled=True,
)
session.add(channel)
click.echo(f"Channel '{name}' added (hash={channel.channel_hash})")
db.dispose()
channel = Channel(
name=name,
key_hex=key_hex.upper(),
channel_hash=Channel.compute_channel_hash(key_hex.upper()),
visibility=visibility,
enabled=True,
)
session.add(channel)
click.echo(f"Channel '{name}' added (hash={channel.channel_hash})")
finally:
db.dispose()
@channel_group.command("remove")
@@ -396,15 +400,16 @@ def channel_remove_cmd(ctx: click.Context, name: str) -> None:
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
db.dispose()
return
session.delete(channel)
click.echo(f"Channel '{name}' removed.")
db.dispose()
try:
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
return
session.delete(channel)
click.echo(f"Channel '{name}' removed.")
finally:
db.dispose()
@channel_group.command("enable")
@@ -418,15 +423,16 @@ def channel_enable_cmd(ctx: click.Context, name: str) -> None:
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
db.dispose()
return
channel.enabled = True
click.echo(f"Channel '{name}' enabled.")
db.dispose()
try:
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
return
channel.enabled = True
click.echo(f"Channel '{name}' enabled.")
finally:
db.dispose()
@channel_group.command("disable")
@@ -440,15 +446,16 @@ def channel_disable_cmd(ctx: click.Context, name: str) -> None:
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
db.dispose()
return
channel.enabled = False
click.echo(f"Channel '{name}' disabled.")
db.dispose()
try:
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
return
channel.enabled = False
click.echo(f"Channel '{name}' disabled.")
finally:
db.dispose()
@collector.command("seed")
+35
View File
@@ -118,6 +118,41 @@ class TestListChannels:
names = {item["name"] for item in data["items"]}
assert names == {"Community", "MemberCh"}
def test_list_channels_operator_sees_community_member_operator(
self, client_no_auth, api_db_session
):
"""Operator role sees community, member, and operator channels."""
pub_key = "AABBCCDDEEFF00112233445566778899"
mem_key = "11223344556677889900AABBCCDDEEFF"
op_key = "0A0B0C0D0E0F10111213141516171819"
adm_key = "FFEEDDCCBBAA99887766554433221100"
for name, key, vis in [
("Community", pub_key, "community"),
("MemberCh", mem_key, "member"),
("OperatorCh", op_key, "operator"),
("AdminCh", adm_key, "admin"),
]:
ch = Channel(
name=name,
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility=vis,
enabled=True,
)
api_db_session.add(ch)
api_db_session.commit()
response = client_no_auth.get(
"/api/v1/channels",
headers={"X-User-Roles": "operator"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 3
names = {item["name"] for item in data["items"]}
assert names == {"Community", "MemberCh", "OperatorCh"}
class TestCreateChannel:
"""Tests for POST /channels endpoint."""
+92 -1
View File
@@ -5,7 +5,13 @@ from unittest.mock import patch
import pytest
from meshcore_hub.common.models import Advertisement, Message, Node, Channel
from meshcore_hub.common.models import (
Advertisement,
Message,
Node,
NodeTag,
Channel,
)
from meshcore_hub.common.models import UserProfile
@@ -575,3 +581,88 @@ class TestDashboardChannelVisibility:
admin_data = response_admin.json()
admin_total = sum(p["count"] for p in admin_data["data"])
assert admin_total >= 1
def test_recent_advertisements_includes_tag_name(
self, client_no_auth, api_db_session
):
"""Recent advertisements resolve tag_name from name tags."""
now = datetime.now(timezone.utc)
pub_key = "aa" * 16
node = Node(
public_key=pub_key,
name="NodeName",
adv_type="CLIENT",
first_seen=now,
last_seen=now,
)
api_db_session.add(node)
api_db_session.commit()
tag = NodeTag(node_id=node.id, key="name", value="TagName")
api_db_session.add(tag)
ad = Advertisement(
public_key=pub_key,
name=None,
adv_type="CLIENT",
received_at=now,
route_type="flood",
)
api_db_session.add(ad)
api_db_session.commit()
response = client_no_auth.get("/api/v1/dashboard/stats")
assert response.status_code == 200
data = response.json()
assert len(data["recent_advertisements"]) == 1
assert data["recent_advertisements"][0]["tag_name"] == "TagName"
def test_operator_sees_community_and_member_channel_counts(
self, client_no_auth, api_db_session
):
"""Operator role sees community + member channels but not admin in stats."""
pub_key = "AABBCCDDEEFF00112233445566778899"
mem_key = "11223344556677889900AABBCCDDEEFF"
adm_key = "FFEEDDCCBBAA99887766554433221100"
pub_idx = int(Channel.compute_channel_hash(pub_key), 16)
mem_idx = int(Channel.compute_channel_hash(mem_key), 16)
adm_idx = int(Channel.compute_channel_hash(adm_key), 16)
for name, key, vis in [
("Community", pub_key, "community"),
("Member", mem_key, "member"),
("Admin", adm_key, "admin"),
]:
ch = Channel(
name=name,
key_hex=key,
channel_hash=Channel.compute_channel_hash(key),
visibility=vis,
enabled=True,
)
api_db_session.add(ch)
api_db_session.commit()
for idx, text in [
(pub_idx, "Pub msg"),
(mem_idx, "Mem msg"),
(adm_idx, "Adm msg"),
]:
msg = Message(
message_type="channel",
channel_idx=idx,
text=text,
received_at=datetime.now(timezone.utc),
)
api_db_session.add(msg)
api_db_session.commit()
response = client_no_auth.get(
"/api/v1/dashboard/stats",
headers={"X-User-Roles": "operator"},
)
assert response.status_code == 200
data = response.json()
assert str(pub_idx) in data["channel_message_counts"]
assert str(mem_idx) in data["channel_message_counts"]
assert str(adm_idx) not in data["channel_message_counts"]
+128
View File
@@ -447,6 +447,99 @@ class TestMessageSort:
assert items[0]["text"] == "Alpha message"
assert items[1]["text"] == "Zebra message"
def test_sort_by_type_desc(self, client_no_auth, api_db_session):
"""sort=type&order=desc sorts by message_type descending."""
now = datetime.now(timezone.utc)
msg_ch = Message(
message_type="channel",
channel_idx=17,
text="Channel msg",
received_at=now,
)
msg_ct = Message(
message_type="contact",
text="Contact msg",
received_at=now,
)
api_db_session.add_all([msg_ch, msg_ct])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=type&order=desc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["message_type"] == "contact"
assert items[1]["message_type"] == "channel"
def test_sort_by_from_desc(self, client_no_auth, api_db_session):
"""sort=from&order=desc sorts by pubkey_prefix descending."""
now = datetime.now(timezone.utc)
msg_b = Message(
message_type="direct",
pubkey_prefix="bb_prefix",
text="From B",
received_at=now,
)
msg_a = Message(
message_type="direct",
pubkey_prefix="aa_prefix",
text="From A",
received_at=now,
)
api_db_session.add_all([msg_b, msg_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=from&order=desc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "From B"
assert items[1]["text"] == "From A"
def test_sort_by_message_desc(self, client_no_auth, api_db_session):
"""sort=message&order=desc sorts by text descending."""
now = datetime.now(timezone.utc)
msg_b = Message(
message_type="direct",
text="Zebra message",
received_at=now,
)
msg_a = Message(
message_type="direct",
text="Alpha message",
received_at=now,
)
api_db_session.add_all([msg_b, msg_a])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=message&order=desc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "Zebra message"
assert items[1]["text"] == "Alpha message"
def test_sort_by_time_asc(self, client_no_auth, api_db_session):
"""sort=time&order=asc sorts by received_at ascending."""
now = datetime.now(timezone.utc)
msg_old = Message(
message_type="direct",
pubkey_prefix="aa",
text="Old msg",
received_at=now - timedelta(hours=1),
)
msg_new = Message(
message_type="direct",
pubkey_prefix="bb",
text="New msg",
received_at=now,
)
api_db_session.add_all([msg_old, msg_new])
api_db_session.commit()
response = client_no_auth.get("/api/v1/messages?sort=time&order=asc")
assert response.status_code == 200
items = response.json()["items"]
assert items[0]["text"] == "Old msg"
assert items[1]["text"] == "New msg"
def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
"""Invalid sort value falls back to default (time desc)."""
now = datetime.now(timezone.utc)
@@ -589,3 +682,38 @@ class TestMessageChannelVisibility:
response = client_no_auth.get(f"/api/v1/messages/{direct_msg.id}")
assert response.status_code == 200
assert response.json()["text"] == "Direct message"
def test_get_message_channel_null_idx_not_filtered(
self, client_no_auth, api_db_session
):
"""Channel message with channel_idx=None bypasses visibility filter."""
msg = Message(
message_type="channel",
channel_idx=None,
text="Channel msg no idx",
received_at=datetime.now(timezone.utc),
)
api_db_session.add(msg)
api_db_session.commit()
response = client_no_auth.get(f"/api/v1/messages/{msg.id}")
assert response.status_code == 200
assert response.json()["text"] == "Channel msg no idx"
def test_get_message_no_observers_without_event_hash(
self, client_no_auth, api_db_session
):
"""Message without event_hash returns empty observers list."""
msg = Message(
message_type="direct",
pubkey_prefix="nohash1",
text="No hash msg",
received_at=datetime.now(timezone.utc),
event_hash=None,
)
api_db_session.add(msg)
api_db_session.commit()
response = client_no_auth.get(f"/api/v1/messages/{msg.id}")
assert response.status_code == 200
assert response.json()["observers"] == []
+374 -29
View File
@@ -2,23 +2,45 @@
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from meshcore_hub.collector.cli import collector
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):
"""Invoking collector without subcommand calls _run_collector_service."""
runner = CliRunner()
mock_settings = MagicMock(
data_home="/tmp/data",
effective_seed_home="/tmp/seed",
effective_database_url="sqlite:///tmp/test.db",
)
mock_settings.model_copy.return_value = mock_settings
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
with (
patch(
@@ -35,14 +57,8 @@ class TestCollectorGroup:
mock_run.assert_called_once()
def test_collector_with_data_home_override(self):
"""--data-home overrides the default data home."""
runner = CliRunner()
mock_settings = MagicMock(
data_home="/default",
effective_seed_home="/default/seed",
effective_database_url="sqlite:///default/db",
)
mock_settings.model_copy.return_value = mock_settings
mock_settings = _make_mock_settings("sqlite:///default/db")
with (
patch(
@@ -68,14 +84,8 @@ class TestCollectorRunSubcommand:
"""Tests for the 'collector run' subcommand."""
def test_run_subcommand_calls_run_service(self):
"""'collector run' delegates to _run_collector_service."""
runner = CliRunner()
mock_settings = MagicMock(
data_home="/tmp/data",
effective_seed_home="/tmp/seed",
effective_database_url="sqlite:///tmp/test.db",
)
mock_settings.model_copy.return_value = mock_settings
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
with (
patch(
@@ -94,14 +104,8 @@ class TestCollectorSeedSubcommand:
"""Tests for the 'collector seed' subcommand."""
def test_seed_command_help(self):
"""'collector seed --help' shows usage."""
runner = CliRunner()
mock_settings = MagicMock(
data_home="/tmp/data",
effective_seed_home="/tmp/seed",
effective_database_url="sqlite:///tmp/test.db",
)
mock_settings.model_copy.return_value = mock_settings
mock_settings = _make_mock_settings("sqlite:///tmp/test.db")
with patch(
"meshcore_hub.common.config.get_collector_settings",
@@ -111,3 +115,344 @@ class TestCollectorSeedSubcommand:
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
@@ -195,3 +195,82 @@ def test_decode_payload_returns_none_for_non_string_raw() -> None:
decoder = LetsMeshPacketDecoder()
assert decoder.decode_payload({"raw": 12345}) is None
assert decoder.decode_payload({"raw": None}) is None
def test_reload_keys_replaces_existing_keys() -> None:
"""reload_keys replaces channel keys and names."""
decoder = LetsMeshPacketDecoder(
channel_keys=["bot=EB50A1BCB3E4E5D7BF69A57C9DADA211"]
)
new_key = "D0BDD6D71538138ED979EEC00D98AD97"
decoder.reload_keys([f"chat={new_key}"])
assert new_key.upper() in decoder._channel_keys
assert "EB50A1BCB3E4E5D7BF69A57C9DADA211" not in decoder._channel_keys
new_hash = LetsMeshPacketDecoder._compute_channel_hash(new_key)
assert decoder._channel_names_by_hash.get(new_hash) == "chat"
def test_reload_keys_with_empty_list_keeps_builtins() -> None:
"""Reloading with empty list retains builtin keys."""
decoder = LetsMeshPacketDecoder(
channel_keys=["bot=EB50A1BCB3E4E5D7BF69A57C9DADA211"]
)
decoder.reload_keys([])
assert "8B3387E9C5CDEA6AC9E5EDBAA115CD72" in decoder._channel_keys
assert "9CD8FCF22A47333B591D96A2B848B73F" in decoder._channel_keys
assert "EB50A1BCB3E4E5D7BF69A57C9DADA211" not in decoder._channel_keys
def test_enrich_payload_decoded_merges_attributes() -> None:
"""_enrich_payload_decoded merges payload object attributes into dict."""
payload_obj = MagicMock()
payload_obj.channel_hash = "AB"
payload_obj.decrypted = {"message": "hello"}
payload_obj.sender_public_key = "DEADBEEF"
payload_obj.cipher_mac = None
payload_obj.ciphertext = None
payload_obj.ciphertext_length = None
payload_obj.destination_hash = None
payload_obj.source_hash = None
payload_obj.path_length = None
payload_obj.path_hashes = None
payload_obj.extra_type = None
payload_obj.extra_data = None
payload_obj.checksum = None
decoded_dict = {
"payload": {
"decoded": {
"type": 5,
}
}
}
LetsMeshPacketDecoder._enrich_payload_decoded(decoded_dict, payload_obj)
decoded = decoded_dict["payload"]["decoded"]
assert decoded["channelHash"] == "AB"
assert decoded["decrypted"] == {"message": "hello"}
assert decoded["senderPublicKey"] == "DEADBEEF"
assert "cipherMac" not in decoded
def test_channel_name_from_decoded_returns_none_for_non_dict() -> None:
"""channel_name_from_decoded returns None for non-dict inputs."""
decoder = LetsMeshPacketDecoder()
assert decoder.channel_name_from_decoded(None) is None
assert decoder.channel_name_from_decoded("string") is None # type: ignore[arg-type]
assert decoder.channel_name_from_decoded(42) is None # type: ignore[arg-type]
assert decoder.channel_name_from_decoded({"payload": "not a dict"}) is None
assert (
decoder.channel_name_from_decoded({"payload": {"decoded": "not a dict"}})
is None
)
assert (
decoder.channel_name_from_decoded(
{"payload": {"decoded": {"channelHash": 123}}}
)
is None
)
+45
View File
@@ -142,3 +142,48 @@ class TestWebSettings:
assert "channels" in features
assert features["channels"] is True
def test_features_dashboard_auto_disables(self) -> None:
"""Dashboard disables when nodes, ads, and messages all off."""
settings = WebSettings(
_env_file=None,
feature_dashboard=True,
feature_nodes=False,
feature_advertisements=False,
feature_messages=False,
)
assert settings.features["dashboard"] is False
def test_features_map_auto_disables_without_nodes(self) -> None:
"""Map disables when nodes feature is off."""
settings = WebSettings(
_env_file=None,
feature_map=True,
feature_nodes=False,
)
assert settings.features["map"] is False
def test_features_members_auto_disables_without_oidc(self) -> None:
"""Members disables when OIDC is not enabled."""
settings = WebSettings(
_env_file=None,
feature_members=True,
oidc_enabled=False,
)
assert settings.features["members"] is False
def test_features_all_enabled_by_default(self) -> None:
"""All features are enabled with default settings."""
settings = WebSettings(
_env_file=None,
oidc_enabled=True,
)
features = settings.features
assert features["dashboard"] is True
assert features["nodes"] is True
assert features["advertisements"] is True
assert features["messages"] is True
assert features["map"] is True
assert features["members"] is True
assert features["channels"] is True
assert features["pages"] is True