Files
Remote-Terminal-for-MeshCore/tests/test_channels_router.py

252 lines
9.4 KiB
Python

"""Tests for the channels router endpoints."""
import time
from unittest.mock import patch
import pytest
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
from app.repository import ChannelRepository, MessageRepository
class TestChannelFloodScopeOverride:
@pytest.mark.asyncio
async def test_sets_channel_flood_scope_override(self, test_db, client):
key = "AA" * 16
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
with patch("app.routers.channels.broadcast_event") as mock_broadcast:
response = await client.post(
f"/api/channels/{key}/flood-scope-override",
json={"flood_scope_override": "Esperance"},
)
assert response.status_code == 200
data = response.json()
assert data["flood_scope_override"] == "#Esperance"
channel = await ChannelRepository.get_by_key(key)
assert channel is not None
assert channel.flood_scope_override == "#Esperance"
mock_broadcast.assert_called_once()
assert mock_broadcast.call_args.args[0] == "channel"
class TestCreateChannel:
@pytest.mark.asyncio
async def test_create_broadcasts_channel_update(self, test_db):
from app.routers.channels import CreateChannelRequest, create_channel
with patch("app.routers.channels.broadcast_event") as mock_broadcast:
result = await create_channel(CreateChannelRequest(name="#mychannel"))
mock_broadcast.assert_called_once()
assert mock_broadcast.call_args.args[0] == "channel"
assert mock_broadcast.call_args.args[1]["key"] == result.key
@pytest.mark.asyncio
async def test_existing_hash_is_not_doubled(self, test_db, client):
key = "CC" * 16
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
response = await client.post(
f"/api/channels/{key}/flood-scope-override",
json={"flood_scope_override": "#Esperance"},
)
assert response.status_code == 200
data = response.json()
assert data["flood_scope_override"] == "#Esperance"
@pytest.mark.asyncio
async def test_blank_override_clears_channel_flood_scope_override(self, test_db, client):
key = "BB" * 16
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
await ChannelRepository.update_flood_scope_override(key, "#Esperance")
response = await client.post(
f"/api/channels/{key}/flood-scope-override",
json={"flood_scope_override": " "},
)
assert response.status_code == 200
data = response.json()
assert data["flood_scope_override"] is None
channel = await ChannelRepository.get_by_key(key)
assert channel is not None
assert channel.flood_scope_override is None
class TestPublicChannelProtection:
@pytest.mark.asyncio
async def test_create_public_uses_canonical_key(self, test_db):
from app.routers.channels import CreateChannelRequest, create_channel
result = await create_channel(CreateChannelRequest(name="Public"))
assert result.key == PUBLIC_CHANNEL_KEY
assert result.name == PUBLIC_CHANNEL_NAME
assert result.is_hashtag is False
@pytest.mark.asyncio
async def test_create_public_rejects_conflicting_key(self, test_db, client):
response = await client.post(
"/api/channels",
json={"name": "Public", "key": "AA" * 16},
)
assert response.status_code == 400
assert "canonical Public key" in response.json()["detail"]
assert await ChannelRepository.get_by_key("AA" * 16) is None
@pytest.mark.asyncio
async def test_create_non_public_rejects_public_key(self, test_db, client):
response = await client.post(
"/api/channels",
json={"name": "Ops", "key": PUBLIC_CHANNEL_KEY},
)
assert response.status_code == 400
assert PUBLIC_CHANNEL_NAME in response.json()["detail"]
@pytest.mark.asyncio
async def test_delete_public_channel_is_rejected(self, test_db, client):
await ChannelRepository.upsert(
key=PUBLIC_CHANNEL_KEY,
name=PUBLIC_CHANNEL_NAME,
is_hashtag=False,
on_radio=False,
)
response = await client.delete(f"/api/channels/{PUBLIC_CHANNEL_KEY}")
assert response.status_code == 400
assert "cannot be deleted" in response.json()["detail"]
channel = await ChannelRepository.get_by_key(PUBLIC_CHANNEL_KEY)
assert channel is not None
class TestChannelDetail:
"""Test GET /api/channels/{key}/detail."""
CHANNEL_KEY = "AABBCCDDAABBCCDDAABBCCDDAABBCCDD"
async def _seed_channel(self):
"""Create a channel in the DB."""
await ChannelRepository.upsert(
key=self.CHANNEL_KEY,
name="#test-channel",
is_hashtag=True,
on_radio=True,
)
async def _insert_message(
self,
conversation_key: str,
text: str,
received_at: int,
sender_key: str | None = None,
sender_name: str | None = None,
) -> int | None:
return await MessageRepository.create(
msg_type="CHAN",
text=text,
received_at=received_at,
conversation_key=conversation_key,
sender_key=sender_key,
sender_name=sender_name,
)
@pytest.mark.asyncio
async def test_detail_basic_stats(self, test_db, client):
"""Channel with messages returns correct counts."""
await self._seed_channel()
now = int(time.time())
# Insert messages at different ages
await self._insert_message(self.CHANNEL_KEY, "recent1", now - 60, "aaa", "Alice")
await self._insert_message(self.CHANNEL_KEY, "recent2", now - 120, "bbb", "Bob")
await self._insert_message(self.CHANNEL_KEY, "old", now - 90000, "aaa", "Alice")
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
assert response.status_code == 200
data = response.json()
assert data["channel"]["key"] == self.CHANNEL_KEY
assert data["channel"]["name"] == "#test-channel"
assert data["message_counts"]["all_time"] == 3
assert data["message_counts"]["last_1h"] == 2
assert data["unique_sender_count"] == 2
assert data["first_message_at"] == now - 90000
@pytest.mark.asyncio
async def test_detail_404_unknown_key(self, test_db, client):
"""Unknown channel key returns 404."""
response = await client.get("/api/channels/FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF/detail")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_detail_empty_stats(self, test_db, client):
"""Channel with no messages returns zeroed stats."""
await self._seed_channel()
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
assert response.status_code == 200
data = response.json()
assert data["message_counts"]["all_time"] == 0
assert data["message_counts"]["last_1h"] == 0
assert data["unique_sender_count"] == 0
assert data["first_message_at"] is None
assert data["top_senders_24h"] == []
@pytest.mark.asyncio
async def test_detail_time_window_bucketing(self, test_db, client):
"""Messages at different ages fall into correct time buckets."""
await self._seed_channel()
now = int(time.time())
# 30 min ago → last_1h, last_24h, last_48h, last_7d
await self._insert_message(self.CHANNEL_KEY, "m1", now - 1800, "aaa")
# 2 hours ago → last_24h, last_48h, last_7d (not last_1h)
await self._insert_message(self.CHANNEL_KEY, "m2", now - 7200, "bbb")
# 30 hours ago → last_48h, last_7d (not last_1h or last_24h)
await self._insert_message(self.CHANNEL_KEY, "m3", now - 108000, "ccc")
# 3 days ago → last_7d only
await self._insert_message(self.CHANNEL_KEY, "m4", now - 259200, "ddd")
# 10 days ago → all_time only
await self._insert_message(self.CHANNEL_KEY, "m5", now - 864000, "eee")
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
data = response.json()
counts = data["message_counts"]
assert counts["last_1h"] == 1
assert counts["last_24h"] == 2
assert counts["last_48h"] == 3
assert counts["last_7d"] == 4
assert counts["all_time"] == 5
@pytest.mark.asyncio
async def test_detail_top_senders_ordering(self, test_db, client):
"""Top senders are ordered by message count descending."""
await self._seed_channel()
now = int(time.time())
# Alice: 3 messages, Bob: 1 message
for i in range(3):
await self._insert_message(
self.CHANNEL_KEY, f"alice-{i}", now - 60 * (i + 1), "aaa", "Alice"
)
await self._insert_message(self.CHANNEL_KEY, "bob-1", now - 300, "bbb", "Bob")
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
data = response.json()
senders = data["top_senders_24h"]
assert len(senders) == 2
assert senders[0]["sender_name"] == "Alice"
assert senders[0]["message_count"] == 3
assert senders[1]["sender_name"] == "Bob"
assert senders[1]["message_count"] == 1