mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-06 09:52:00 +02:00
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:
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user