"""Tests for API endpoints. These tests verify the REST API behavior for critical operations. Uses httpx.AsyncClient or direct function calls with real in-memory SQLite. """ import hashlib import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException import app.services.message_send as message_send_service from app.radio import radio_manager from app.repository import ( ChannelRepository, ContactRepository, MessageRepository, RawPacketRepository, ) from app.version_info import AppBuildInfo @pytest.fixture(autouse=True) def _reset_radio_state(): """Save/restore radio_manager state so tests don't leak.""" prev = radio_manager._meshcore prev_lock = radio_manager._operation_lock prev_max_channels = radio_manager.max_channels prev_connection_info = radio_manager._connection_info prev_slot_by_key = radio_manager._channel_slot_by_key.copy() prev_key_by_slot = radio_manager._channel_key_by_slot.copy() yield radio_manager._meshcore = prev radio_manager._operation_lock = prev_lock radio_manager.max_channels = prev_max_channels radio_manager._connection_info = prev_connection_info radio_manager._channel_slot_by_key = prev_slot_by_key radio_manager._channel_key_by_slot = prev_key_by_slot @pytest.fixture(autouse=True) def _disable_background_dm_retries(monkeypatch): monkeypatch.setattr(message_send_service, "DM_SEND_MAX_ATTEMPTS", 1) yield def _patch_require_connected(mc=None, *, detail="Radio not connected"): if mc is None: return patch( "app.dependencies.radio_manager.require_connected", side_effect=HTTPException(status_code=503, detail=detail), ) return patch("app.dependencies.radio_manager.require_connected", return_value=mc) async def _insert_contact(public_key, name="Alice", **overrides): """Insert a contact into the test database.""" data = { "public_key": public_key, "name": name, "type": 0, "flags": 0, "direct_path": None, "direct_path_len": -1, "direct_path_hash_mode": -1, "last_advert": None, "lat": None, "lon": None, "last_seen": None, "on_radio": False, "last_contacted": None, } data.update(overrides) await ContactRepository.upsert(data) class TestHealthEndpoint: """Test the health check endpoint.""" def test_health_returns_connection_status(self): """Health endpoint returns radio connection status.""" from fastapi.testclient import TestClient with patch("app.routers.health.radio_manager") as mock_rm: mock_rm.is_connected = True mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.is_setup_in_progress = False mock_rm.is_setup_complete = True mock_rm.connection_desired = True mock_rm.is_reconnecting = False mock_rm.device_info_loaded = False from app.main import app client = TestClient(app) response = client.get("/api/health") assert response.status_code == 200 data = response.json() assert data["radio_connected"] is True assert data["connection_info"] == "Serial: /dev/ttyUSB0" def test_health_disconnected_state(self): """Health endpoint reflects disconnected radio.""" from fastapi.testclient import TestClient with patch("app.routers.health.radio_manager") as mock_rm: mock_rm.is_connected = False mock_rm.connection_info = None mock_rm.is_setup_in_progress = False mock_rm.is_setup_complete = False mock_rm.connection_desired = True mock_rm.is_reconnecting = False mock_rm.device_info_loaded = False from app.main import app client = TestClient(app) response = client.get("/api/health") assert response.status_code == 200 data = response.json() assert data["radio_connected"] is False assert data["connection_info"] is None class TestDebugEndpoint: """Test the debug support snapshot endpoint.""" @pytest.mark.asyncio async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client): """Debug snapshot should still return logs and runtime state when radio is disconnected.""" from app.config import clear_recent_log_lines from app.routers.debug import DebugApplicationInfo clear_recent_log_lines() radio_manager._meshcore = None radio_manager._connection_info = None with patch( "app.routers.debug._build_application_info", return_value=DebugApplicationInfo( version="3.2.0", version_source="pyproject", commit_hash="deadbeef", commit_source="git", git_branch="main", git_dirty=False, python_version="3.12.0", ), ): response = await client.get("/api/debug") assert response.status_code == 200 payload = response.json() assert payload["radio_probe"]["performed"] is False assert payload["radio_probe"]["errors"] == ["Radio not connected"] assert payload["runtime"]["channels_with_incoming_messages"] == 0 assert payload["database"]["total_dms"] == 0 assert payload["database"]["total_channel_messages"] == 0 assert payload["database"]["total_outgoing"] == 0 @pytest.mark.asyncio async def test_support_snapshot_includes_database_message_totals(self, test_db, client): """Debug snapshot includes stored DM/channel/outgoing totals.""" await _insert_contact("ab" * 32, "Alice") await test_db.conn.execute( "INSERT INTO channels (key, name, is_hashtag, on_radio) VALUES (?, ?, ?, ?)", ("CD" * 16, "#ops", 1, 0), ) await test_db.conn.execute( "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)", ("PRIV", "ab" * 32, "hello", 1000, 0), ) await test_db.conn.execute( "INSERT INTO messages (type, conversation_key, text, received_at, outgoing) VALUES (?, ?, ?, ?, ?)", ("CHAN", "CD" * 16, "room msg", 1001, 1), ) await test_db.conn.commit() response = await client.get("/api/debug") assert response.status_code == 200 payload = response.json() assert payload["database"]["total_dms"] == 1 assert payload["database"]["total_channel_messages"] == 1 assert payload["database"]["total_outgoing"] == 1 class TestRadioDisconnectedHandler: """Test that RadioDisconnectedError maps to 503.""" @pytest.mark.asyncio async def test_disconnect_race_returns_503(self, test_db, client): """If radio disconnects between require_connected() and lock acquisition, return 503.""" pub_key = "ab" * 32 await _insert_contact(pub_key, "Alice") # require_connected() passes, but _meshcore is None when radio_operation() checks radio_manager._meshcore = None with _patch_require_connected(MagicMock()): response = await client.post( "/api/messages/direct", json={"destination": pub_key, "text": "Hi"} ) assert response.status_code == 503 assert "not connected" in response.json()["detail"].lower() class TestDebugApplicationInfo: """Test debug application metadata resolution.""" def test_build_application_info_uses_release_build_info_without_git(self, tmp_path): """Release bundles should still surface commit metadata without a .git directory.""" from app.routers import debug as debug_router with ( patch( "app.routers.debug.get_app_build_info", return_value=AppBuildInfo( version="3.4.0", version_source="pyproject", commit_hash="cf1a55e2", commit_source="build_info", ), ), patch("app.routers.debug.git_output", return_value=None), ): info = debug_router._build_application_info() assert info.version == "3.4.0" assert info.version_source == "pyproject" assert info.commit_hash == "cf1a55e2" assert info.commit_source == "build_info" assert info.git_branch is None assert info.git_dirty is False def test_build_application_info_ignores_invalid_release_build_info(self, tmp_path): """Malformed release metadata should not break the debug endpoint.""" from app.routers import debug as debug_router with ( patch( "app.routers.debug.get_app_build_info", return_value=AppBuildInfo( version="3.4.0", version_source="pyproject", commit_hash=None, commit_source=None, ), ), patch("app.routers.debug.git_output", return_value=None), ): info = debug_router._build_application_info() assert info.version == "3.4.0" assert info.version_source == "pyproject" assert info.commit_hash is None assert info.commit_source is None assert info.git_branch is None assert info.git_dirty is False class TestMessagesEndpoint: """Test message-related endpoints.""" @pytest.mark.asyncio async def test_send_direct_message_requires_connection(self, test_db, client): """Sending message when disconnected returns 503.""" with _patch_require_connected(): response = await client.post( "/api/messages/direct", json={"destination": "abc123", "text": "Hello"} ) assert response.status_code == 503 assert "not connected" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_send_channel_message_requires_connection(self, test_db, client): """Sending channel message when disconnected returns 503.""" with _patch_require_connected(): response = await client.post( "/api/messages/channel", json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"}, ) assert response.status_code == 503 @pytest.mark.asyncio async def test_send_direct_message_emits_websocket_message_event(self, test_db, client): """POST /messages/direct should emit a WS message event for other clients.""" from meshcore import EventType pub_key = "ab" * 32 await _insert_contact(pub_key, "Alice") mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix.return_value = {"public_key": pub_key} mock_mc.commands.add_contact = AsyncMock( return_value=MagicMock(type=EventType.OK, payload={}) ) mock_mc.commands.send_msg = AsyncMock( return_value=MagicMock(type=EventType.MSG_SENT, payload={}) ) radio_manager._meshcore = mock_mc with ( _patch_require_connected(mock_mc), patch("app.routers.messages.broadcast_event") as mock_broadcast, ): response = await client.post( "/api/messages/direct", json={"destination": pub_key, "text": "Hello"}, ) assert response.status_code == 200 mock_broadcast.assert_called_once() event_type, payload = mock_broadcast.call_args.args assert event_type == "message" assert payload["type"] == "PRIV" # Verify message was stored in real DB messages = await MessageRepository.get_all(conversation_key=pub_key) assert len(messages) == 1 assert messages[0].text == "Hello" @pytest.mark.asyncio async def test_send_channel_message_emits_websocket_message_event(self, test_db, client): """POST /messages/channel should emit a WS message event for other clients.""" from meshcore import EventType chan_key = "AA" * 16 await ChannelRepository.upsert(key=chan_key, name="Public") mock_mc = MagicMock() mock_mc.self_info = {"name": "TestNode"} ok_result = MagicMock(type=EventType.MSG_SENT, payload={}) mock_mc.commands.set_channel = AsyncMock(return_value=ok_result) mock_mc.commands.send_chan_msg = AsyncMock(return_value=ok_result) radio_manager._meshcore = mock_mc with ( _patch_require_connected(mock_mc), patch("app.routers.messages.broadcast_event") as mock_broadcast, ): response = await client.post( "/api/messages/channel", json={"channel_key": chan_key, "text": "Hello room"}, ) assert response.status_code == 200 mock_broadcast.assert_called_once() event_type, payload = mock_broadcast.call_args.args assert event_type == "message" assert payload["type"] == "CHAN" @pytest.mark.asyncio async def test_send_direct_message_contact_not_found(self, test_db, client): """Sending to unknown contact returns 404.""" mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix.return_value = None with _patch_require_connected(mock_mc): response = await client.post( "/api/messages/direct", json={"destination": "nonexistent", "text": "Hello"} ) assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_send_direct_message_duplicate_returns_500(self, test_db): """If MessageRepository.create returns None (duplicate), returns 500.""" from app.models import SendDirectMessageRequest from app.routers.messages import send_direct_message pub_key = "a" * 64 await _insert_contact(pub_key, "TestContact") mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix.return_value = {"public_key": pub_key} mock_mc.commands.add_contact = AsyncMock( return_value=MagicMock(type=MagicMock(name="OK"), payload={}) ) mock_mc.commands.send_msg = AsyncMock( return_value=MagicMock(type=MagicMock(name="OK"), payload={"expected_ack": b"\x00\x01"}) ) radio_manager._meshcore = mock_mc with ( _patch_require_connected(mock_mc), patch("app.routers.messages.MessageRepository") as mock_msg_repo, ): mock_msg_repo.get_by_content = AsyncMock(return_value=None) # Simulate duplicate - create returns None mock_msg_repo.create = AsyncMock(return_value=None) from fastapi import HTTPException with pytest.raises(HTTPException) as exc_info: await send_direct_message( SendDirectMessageRequest(destination=pub_key, text="Hello") ) assert exc_info.value.status_code == 500 assert "unexpected duplicate" in exc_info.value.detail.lower() @pytest.mark.asyncio async def test_send_channel_message_duplicate_returns_500(self, test_db): """If MessageRepository.create returns None (duplicate), returns 500.""" from app.models import SendChannelMessageRequest from app.routers.messages import send_channel_message chan_key = "0123456789ABCDEF0123456789ABCDEF" await ChannelRepository.upsert(key=chan_key, name="test") mock_mc = MagicMock() mock_mc.commands.send_chan_msg = AsyncMock( return_value=MagicMock(type=MagicMock(name="OK"), payload={}) ) mock_mc.commands.set_channel = AsyncMock( return_value=MagicMock(type=MagicMock(name="OK"), payload={}) ) radio_manager._meshcore = mock_mc with ( _patch_require_connected(mock_mc), patch("app.routers.messages.MessageRepository") as mock_msg_repo, ): mock_msg_repo.get_by_content = AsyncMock(return_value=None) # Simulate duplicate - create returns None mock_msg_repo.create = AsyncMock(return_value=None) from fastapi import HTTPException with pytest.raises(HTTPException) as exc_info: await send_channel_message( SendChannelMessageRequest(channel_key=chan_key, text="Hello") ) assert exc_info.value.status_code == 500 assert "unexpected duplicate" in exc_info.value.detail.lower() @pytest.mark.asyncio async def test_resend_channel_message_requires_connection(self, test_db, client): """Resend endpoint returns 503 when radio is disconnected.""" with _patch_require_connected(): response = await client.post("/api/messages/channel/1/resend") assert response.status_code == 503 assert "not connected" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_resend_channel_message_success(self, test_db, client): """Resend endpoint reuses timestamp bytes and strips sender prefix.""" from meshcore import EventType chan_key = "AB" * 16 await ChannelRepository.upsert(key=chan_key, name="#resend") sent_at = int(time.time()) - 5 msg_id = await MessageRepository.create( msg_type="CHAN", text="TestNode: hello world", conversation_key=chan_key, sender_timestamp=sent_at, received_at=sent_at, outgoing=True, ) assert msg_id is not None mock_mc = MagicMock() mock_mc.self_info = {"name": "TestNode"} mock_mc.commands = MagicMock() mock_mc.commands.set_channel = AsyncMock( return_value=MagicMock(type=EventType.OK, payload={}) ) mock_mc.commands.send_chan_msg = AsyncMock( return_value=MagicMock(type=EventType.MSG_SENT, payload={}) ) radio_manager._meshcore = mock_mc with _patch_require_connected(mock_mc): response = await client.post(f"/api/messages/channel/{msg_id}/resend") assert response.status_code == 200 assert response.json() == {"status": "ok", "message_id": msg_id} set_kwargs = mock_mc.commands.set_channel.await_args.kwargs assert set_kwargs["channel_idx"] == 0 assert set_kwargs["channel_name"] == "#resend" assert set_kwargs["channel_secret"] == bytes.fromhex(chan_key) send_kwargs = mock_mc.commands.send_chan_msg.await_args.kwargs assert send_kwargs["chan"] == 0 assert send_kwargs["msg"] == "hello world" assert send_kwargs["timestamp"] == sent_at.to_bytes(4, "little") @pytest.mark.asyncio async def test_resend_channel_message_new_timestamp_returns_message_payload( self, test_db, client ): """New-timestamp resend returns the created message payload for local UI append.""" from meshcore import EventType chan_key = "EF" * 16 await ChannelRepository.upsert(key=chan_key, name="#resend-new") sent_at = int(time.time()) - 5 msg_id = await MessageRepository.create( msg_type="CHAN", text="TestNode: hello again", conversation_key=chan_key, sender_timestamp=sent_at, received_at=sent_at, outgoing=True, ) assert msg_id is not None mock_mc = MagicMock() mock_mc.self_info = {"name": "TestNode", "public_key": "ab" * 32} mock_mc.commands = MagicMock() mock_mc.commands.set_channel = AsyncMock( return_value=MagicMock(type=EventType.OK, payload={}) ) mock_mc.commands.send_chan_msg = AsyncMock( return_value=MagicMock(type=EventType.MSG_SENT, payload={}) ) radio_manager._meshcore = mock_mc with _patch_require_connected(mock_mc): response = await client.post( f"/api/messages/channel/{msg_id}/resend?new_timestamp=true" ) assert response.status_code == 200 payload = response.json() assert payload["status"] == "ok" assert payload["message_id"] != msg_id assert payload["message"]["id"] == payload["message_id"] assert payload["message"]["conversation_key"] == chan_key assert payload["message"]["outgoing"] is True @pytest.mark.asyncio async def test_resend_channel_message_window_expired(self, test_db, client): """Resend endpoint rejects channel messages older than 30 seconds.""" chan_key = "CD" * 16 await ChannelRepository.upsert(key=chan_key, name="#old") sent_at = int(time.time()) - 60 msg_id = await MessageRepository.create( msg_type="CHAN", text="TestNode: too old", conversation_key=chan_key, sender_timestamp=sent_at, received_at=sent_at, outgoing=True, ) assert msg_id is not None mock_mc = MagicMock() mock_mc.self_info = {"name": "TestNode"} mock_mc.commands = MagicMock() mock_mc.commands.set_channel = AsyncMock() mock_mc.commands.send_chan_msg = AsyncMock() with _patch_require_connected(mock_mc): response = await client.post(f"/api/messages/channel/{msg_id}/resend") assert response.status_code == 400 assert "expired" in response.json()["detail"].lower() assert mock_mc.commands.set_channel.await_count == 0 assert mock_mc.commands.send_chan_msg.await_count == 0 @pytest.mark.asyncio async def test_resend_channel_message_returns_404_for_missing(self, test_db, client): """Resend endpoint returns 404 for nonexistent message ID.""" mock_mc = MagicMock() mock_mc.self_info = {"name": "TestNode"} mock_mc.commands = MagicMock() mock_mc.commands.set_channel = AsyncMock() mock_mc.commands.send_chan_msg = AsyncMock() with _patch_require_connected(mock_mc): response = await client.post("/api/messages/channel/999999/resend") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() assert mock_mc.commands.set_channel.await_count == 0 assert mock_mc.commands.send_chan_msg.await_count == 0 class TestChannelsEndpoint: """Test channel-related endpoints.""" @pytest.mark.asyncio async def test_create_hashtag_channel_derives_key(self, test_db): """Creating hashtag channel derives key from name and stores in DB.""" from app.routers.channels import CreateChannelRequest, create_channel request = CreateChannelRequest(name="#mychannel") result = await create_channel(request) # Verify the key derivation expected_key_hex = hashlib.sha256(b"#mychannel").digest()[:16].hex().upper() assert result.key == expected_key_hex assert result.name == "#mychannel" # Verify stored in real DB channel = await ChannelRepository.get_by_key(expected_key_hex) assert channel is not None assert channel.name == "#mychannel" assert channel.is_hashtag is True assert channel.on_radio is False @pytest.mark.asyncio async def test_create_channel_with_explicit_key(self, test_db): """Creating channel with explicit key uses provided key.""" from app.routers.channels import CreateChannelRequest, create_channel explicit_key = "0123456789abcdef0123456789abcdef" # 32 hex chars = 16 bytes request = CreateChannelRequest(name="private", key=explicit_key) result = await create_channel(request) assert result.key == explicit_key.upper() # Verify stored in real DB channel = await ChannelRepository.get_by_key(explicit_key.upper()) assert channel is not None assert channel.name == "private" assert channel.on_radio is False class TestPacketsEndpoint: """Test packet decryption endpoints.""" def test_get_undecrypted_count(self): """Get undecrypted packet count returns correct value.""" from fastapi.testclient import TestClient with patch("app.routers.packets.RawPacketRepository") as mock_repo: mock_repo.get_undecrypted_count = AsyncMock(return_value=42) from app.main import app client = TestClient(app) response = client.get("/api/packets/undecrypted/count") assert response.status_code == 200 assert response.json()["count"] == 42 class TestReadStateEndpoints: """Test read state tracking endpoints.""" @pytest.mark.asyncio async def test_mark_contact_read_updates_timestamp(self, test_db): """Marking contact as read updates last_read_at in database.""" pub_key = "abc123def456789012345678901234567890123456789012345678901234" await _insert_contact(pub_key, "TestContact") before_time = int(time.time()) updated = await ContactRepository.update_last_read_at(pub_key) assert updated is True contact = await ContactRepository.get_by_key(pub_key) assert contact is not None assert contact.last_read_at is not None assert contact.last_read_at >= before_time @pytest.mark.asyncio async def test_mark_channel_read_updates_timestamp(self, test_db): """Marking channel as read updates last_read_at in database.""" chan_key = "0123456789ABCDEF0123456789ABCDEF" await ChannelRepository.upsert(key=chan_key, name="#testchannel") before_time = int(time.time()) updated = await ChannelRepository.update_last_read_at(chan_key) assert updated is True channel = await ChannelRepository.get_by_key(chan_key) assert channel is not None assert channel.last_read_at is not None assert channel.last_read_at >= before_time @pytest.mark.asyncio async def test_mark_nonexistent_contact_returns_false(self, test_db): """Marking nonexistent contact returns False.""" updated = await ContactRepository.update_last_read_at("nonexistent") assert updated is False @pytest.mark.asyncio async def test_mark_contact_read_endpoint_returns_404_for_missing(self, test_db, client): """Mark-read endpoint returns 404 for nonexistent contact.""" response = await client.post("/api/contacts/nonexistent/mark-read") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_mark_channel_read_endpoint_returns_404_for_missing(self, test_db, client): """Mark-read endpoint returns 404 for nonexistent channel.""" response = await client.post("/api/channels/NONEXISTENT/mark-read") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_get_unreads_returns_counts_and_mentions(self, test_db): """GET /unreads returns unread counts, mentions, and last message times.""" chan_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1" contact_key = "abcd" * 16 await ChannelRepository.upsert(key=chan_key, name="Public") await ChannelRepository.update_last_read_at(chan_key, 1000) await _insert_contact(contact_key, "Alice") await ContactRepository.update_last_read_at(contact_key, 1000) # 2 unread channel msgs (received_at > last_read_at=1000), 1 read, 1 outgoing await MessageRepository.create( msg_type="CHAN", text="Bob: hello", received_at=1001, conversation_key=chan_key, sender_timestamp=1001, ) await MessageRepository.create( msg_type="CHAN", text="Bob: @[testuser] hey", received_at=1002, conversation_key=chan_key, sender_timestamp=1002, ) await MessageRepository.create( msg_type="CHAN", text="Bob: old msg", received_at=999, conversation_key=chan_key, sender_timestamp=999, ) await MessageRepository.create( msg_type="CHAN", text="Me: outgoing", received_at=1003, conversation_key=chan_key, sender_timestamp=1003, outgoing=True, ) # 1 unread DM with mention await MessageRepository.create( msg_type="PRIV", text="hi @[TeStUsEr] there", received_at=1005, conversation_key=contact_key, sender_timestamp=1005, ) result = await MessageRepository.get_unread_counts("TestUser") # Channel: 2 unread (1001 and 1002), one has mention assert result["counts"][f"channel-{chan_key}"] == 2 assert result["mentions"][f"channel-{chan_key}"] is True # Contact: 1 unread with mention (case-insensitive) assert result["counts"][f"contact-{contact_key}"] == 1 assert result["mentions"][f"contact-{contact_key}"] is True # Last message times should include all conversations assert result["last_message_times"][f"channel-{chan_key}"] == 1003 assert result["last_message_times"][f"contact-{contact_key}"] == 1005 assert result["last_read_ats"][f"channel-{chan_key}"] == 1000 assert result["last_read_ats"][f"contact-{contact_key}"] == 1000 @pytest.mark.asyncio async def test_get_unreads_no_name_skips_mentions(self, test_db): """Unreads without a radio name returns counts but no mention flags.""" chan_key = "CHAN1KEY1CHAN1KEY1CHAN1KEY1CHAN1KEY1" await ChannelRepository.upsert(key=chan_key, name="Public") await ChannelRepository.update_last_read_at(chan_key, 0) await MessageRepository.create( msg_type="CHAN", text="Bob: @[Alice] hey", received_at=1001, conversation_key=chan_key, sender_timestamp=1001, ) result = await MessageRepository.get_unread_counts(None) assert result["counts"][f"channel-{chan_key}"] == 1 assert len(result["mentions"]) == 0 @pytest.mark.asyncio async def test_unreads_endpoint_sources_name_from_radio(self, test_db, client): """GET /unreads sources the user's name from the radio for mention detection.""" chan_key = "MENTIONENDPOINT1MENTIONENDPOINT1" await ChannelRepository.upsert(key=chan_key, name="Public") await ChannelRepository.update_last_read_at(chan_key, 0) await MessageRepository.create( msg_type="CHAN", text="hey @[RadioUser] check this", received_at=1001, conversation_key=chan_key, sender_timestamp=1001, ) # Mock radio_manager.meshcore to return a name mock_mc = MagicMock() mock_mc.self_info = {"name": "RadioUser"} with patch("app.routers.read_state.radio_manager") as mock_rm: mock_rm.meshcore = mock_mc response = await client.get("/api/read-state/unreads") assert response.status_code == 200 data = response.json() assert data["counts"][f"channel-{chan_key}"] == 1 assert data["mentions"][f"channel-{chan_key}"] is True assert data["last_read_ats"][f"channel-{chan_key}"] == 0 @pytest.mark.asyncio async def test_unreads_endpoint_no_radio_skips_mentions(self, test_db, client): """GET /unreads with no radio connected still returns counts without mentions.""" chan_key = "NORADIOENDPOINT1NORADIOENDPOINT1" await ChannelRepository.upsert(key=chan_key, name="Public") await ChannelRepository.update_last_read_at(chan_key, 0) await MessageRepository.create( msg_type="CHAN", text="hey @[Someone] check this", received_at=1001, conversation_key=chan_key, sender_timestamp=1001, ) # Mock radio_manager.meshcore as None (disconnected) with patch("app.routers.read_state.radio_manager") as mock_rm: mock_rm.meshcore = None response = await client.get("/api/read-state/unreads") assert response.status_code == 200 data = response.json() assert data["counts"][f"channel-{chan_key}"] == 1 assert len(data["mentions"]) == 0 assert data["last_read_ats"][f"channel-{chan_key}"] == 0 @pytest.mark.asyncio async def test_unreads_reset_after_mark_read(self, test_db): """Marking a conversation as read zeroes its unread count; new messages after count again.""" chan_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1" await ChannelRepository.upsert(key=chan_key, name="Public") await ChannelRepository.update_last_read_at(chan_key, 1000) # 2 unread messages (received_at > last_read_at=1000) await MessageRepository.create( msg_type="CHAN", text="msg1", received_at=1001, conversation_key=chan_key, sender_timestamp=1001, ) await MessageRepository.create( msg_type="CHAN", text="msg2", received_at=1002, conversation_key=chan_key, sender_timestamp=1002, ) # Verify 2 unread result = await MessageRepository.get_unread_counts(None) assert result["counts"][f"channel-{chan_key}"] == 2 # Mark as read await ChannelRepository.update_last_read_at(chan_key, 1002) # Verify 0 unread result = await MessageRepository.get_unread_counts(None) assert result["counts"].get(f"channel-{chan_key}", 0) == 0 # New message arrives after the read point await MessageRepository.create( msg_type="CHAN", text="msg3", received_at=1003, conversation_key=chan_key, sender_timestamp=1003, ) # Verify exactly 1 unread result = await MessageRepository.get_unread_counts(None) assert result["counts"][f"channel-{chan_key}"] == 1 @pytest.mark.asyncio async def test_unreads_exclude_outgoing_messages(self, test_db): """Outgoing messages should never count as unread.""" contact_key = "abcd" * 16 await _insert_contact(contact_key, "Bob") await ContactRepository.update_last_read_at(contact_key, 1000) # 1 incoming (should count) + 2 outgoing (should NOT count) await MessageRepository.create( msg_type="PRIV", text="incoming msg", received_at=1001, conversation_key=contact_key, sender_timestamp=1001, ) await MessageRepository.create( msg_type="PRIV", text="my reply", received_at=1002, conversation_key=contact_key, sender_timestamp=1002, outgoing=True, ) await MessageRepository.create( msg_type="PRIV", text="another reply", received_at=1003, conversation_key=contact_key, sender_timestamp=1003, outgoing=True, ) result = await MessageRepository.get_unread_counts(None) # Only the 1 incoming message should count as unread assert result["counts"][f"contact-{contact_key}"] == 1 @pytest.mark.asyncio async def test_mark_all_read_updates_all_conversations(self, test_db): """Bulk mark-all-read updates all contacts and channels.""" await _insert_contact("contact1", "Alice") await _insert_contact("contact2", "Bob") await ChannelRepository.upsert(key="CHAN1KEY1CHAN1KEY1CHAN1KEY1CHAN1KEY1", name="#test1") await ChannelRepository.upsert(key="CHAN2KEY2CHAN2KEY2CHAN2KEY2CHAN2KEY2", name="#test2") before_time = int(time.time()) from app.routers.read_state import mark_all_read result = await mark_all_read() assert result["status"] == "ok" assert result["timestamp"] >= before_time # Verify all contacts updated for key in ["contact1", "contact2"]: contact = await ContactRepository.get_by_key(key) assert contact.last_read_at >= before_time # Verify all channels updated for key in ["CHAN1KEY1CHAN1KEY1CHAN1KEY1CHAN1KEY1", "CHAN2KEY2CHAN2KEY2CHAN2KEY2CHAN2KEY2"]: channel = await ChannelRepository.get_by_key(key) assert channel.last_read_at >= before_time class TestRawPacketRepository: """Test raw packet storage with deduplication.""" @pytest.mark.asyncio async def test_create_returns_id_for_new_packet(self, test_db): """First insert of packet data returns a valid ID.""" packet_data = b"\x01\x02\x03\x04\x05" packet_id, is_new = await RawPacketRepository.create(packet_data, 1234567890) assert packet_id is not None assert packet_id > 0 assert is_new is True @pytest.mark.asyncio async def test_different_packets_both_stored(self, test_db): """Different packet data both get stored with unique IDs.""" packet1 = b"\x01\x02\x03" packet2 = b"\x04\x05\x06" id1, is_new1 = await RawPacketRepository.create(packet1, 1234567890) id2, is_new2 = await RawPacketRepository.create(packet2, 1234567891) assert id1 is not None assert id2 is not None assert id1 != id2 assert is_new1 is True assert is_new2 is True @pytest.mark.asyncio async def test_duplicate_packet_returns_existing_id(self, test_db): """Inserting same payload twice returns existing ID and is_new=False.""" # Same packet data inserted twice packet_data = b"\x01\x02\x03\x04\x05" id1, is_new1 = await RawPacketRepository.create(packet_data, 1234567890) id2, is_new2 = await RawPacketRepository.create(packet_data, 1234567891) # Both should return the same ID assert id1 == id2 # First is new, second is not assert is_new1 is True assert is_new2 is False @pytest.mark.asyncio async def test_malformed_packet_uses_full_data_hash(self, test_db): """Malformed packets (can't extract payload) hash full data for dedup.""" # Single byte is too short to be valid packet (extract_payload returns None) malformed = b"\x01" id1, is_new1 = await RawPacketRepository.create(malformed, 1234567890) id2, is_new2 = await RawPacketRepository.create(malformed, 1234567891) # Should still deduplicate using full data hash assert id1 == id2 assert is_new1 is True assert is_new2 is False # Different malformed packet should get different ID different_malformed = b"\x02" id3, is_new3 = await RawPacketRepository.create(different_malformed, 1234567892) assert id3 != id1 assert is_new3 is True @pytest.mark.asyncio async def test_prune_old_undecrypted_deletes_old_packets(self, test_db): """Prune deletes undecrypted packets older than specified days.""" now = int(time.time()) old_timestamp = now - (15 * 86400) # 15 days ago recent_timestamp = now - (5 * 86400) # 5 days ago # Insert old undecrypted packet await RawPacketRepository.create(b"\x01\x02\x03", old_timestamp) # Insert recent undecrypted packet await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp) # Insert old but decrypted packet (should NOT be deleted) old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp) await RawPacketRepository.mark_decrypted(old_id, 1) # Prune packets older than 10 days deleted = await RawPacketRepository.prune_old_undecrypted(10) assert deleted == 1 # Only the old undecrypted packet @pytest.mark.asyncio async def test_prune_old_undecrypted_returns_zero_when_nothing_to_delete(self, test_db): """Prune returns 0 when no packets match criteria.""" now = int(time.time()) recent_timestamp = now - (5 * 86400) # 5 days ago # Insert only recent packet await RawPacketRepository.create(b"\x01\x02\x03", recent_timestamp) # Prune packets older than 10 days (none should match) deleted = await RawPacketRepository.prune_old_undecrypted(10) assert deleted == 0 @pytest.mark.asyncio async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db): """Purge linked raw packets removes only rows with a message_id.""" ts = int(time.time()) linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts) linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts) await RawPacketRepository.mark_decrypted(linked_1, 101) await RawPacketRepository.mark_decrypted(linked_2, 102) await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain deleted = await RawPacketRepository.purge_linked_to_messages() assert deleted == 2 remaining = await RawPacketRepository.get_undecrypted_count() assert remaining == 1 class TestMaintenanceEndpoint: """Test database maintenance endpoint.""" @pytest.mark.asyncio async def test_maintenance_prunes_and_vacuums(self, test_db): """Maintenance endpoint prunes old packets and runs vacuum.""" from app.routers.packets import MaintenanceRequest, run_maintenance now = int(time.time()) old_timestamp = now - (20 * 86400) # 20 days ago # Insert old undecrypted packets await RawPacketRepository.create(b"\x01\x02\x03", old_timestamp) await RawPacketRepository.create(b"\x04\x05\x06", old_timestamp) request = MaintenanceRequest(prune_undecrypted_days=14) result = await run_maintenance(request) assert result.packets_deleted == 2 assert result.vacuumed is True @pytest.mark.asyncio async def test_maintenance_can_purge_linked_raw_packets(self, test_db): """Maintenance endpoint can purge raw packets linked to messages.""" from app.routers.packets import MaintenanceRequest, run_maintenance ts = int(time.time()) linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts) linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts) await RawPacketRepository.mark_decrypted(linked_1, 201) await RawPacketRepository.mark_decrypted(linked_2, 202) request = MaintenanceRequest(purge_linked_raw_packets=True) result = await run_maintenance(request) assert result.packets_deleted == 2 assert result.vacuumed is True class TestHealthEndpointDatabaseSize: """Test database size reporting in health endpoint.""" def test_health_includes_database_size(self): """Health endpoint includes database_size_mb field.""" from unittest.mock import patch from fastapi.testclient import TestClient with ( patch("app.routers.health.radio_manager") as mock_rm, patch("app.routers.health.os.path.getsize") as mock_getsize, ): mock_rm.is_connected = True mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.is_setup_in_progress = False mock_rm.is_setup_complete = True mock_rm.connection_desired = True mock_rm.is_reconnecting = False mock_rm.device_info_loaded = False mock_getsize.return_value = 10 * 1024 * 1024 # 10 MB from app.main import app client = TestClient(app) response = client.get("/api/health") assert response.status_code == 200 data = response.json() assert "database_size_mb" in data assert data["database_size_mb"] == 10.0 class TestHealthEndpointOldestUndecrypted: """Test oldest undecrypted packet timestamp in health endpoint.""" def test_health_includes_oldest_undecrypted_timestamp(self): """Health endpoint includes oldest_undecrypted_timestamp when packets exist.""" from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient with ( patch("app.routers.health.radio_manager") as mock_rm, patch("app.routers.health.os.path.getsize") as mock_getsize, patch("app.routers.health.RawPacketRepository") as mock_repo, ): mock_rm.is_connected = True mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.is_setup_in_progress = False mock_rm.is_setup_complete = True mock_rm.connection_desired = True mock_rm.is_reconnecting = False mock_rm.device_info_loaded = False mock_getsize.return_value = 5 * 1024 * 1024 # 5 MB mock_repo.get_oldest_undecrypted = AsyncMock(return_value=1700000000) from app.main import app client = TestClient(app) response = client.get("/api/health") assert response.status_code == 200 data = response.json() assert "oldest_undecrypted_timestamp" in data assert data["oldest_undecrypted_timestamp"] == 1700000000 def test_health_oldest_undecrypted_null_when_none(self): """Health endpoint returns null for oldest_undecrypted_timestamp when no packets.""" from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient with ( patch("app.routers.health.radio_manager") as mock_rm, patch("app.routers.health.os.path.getsize") as mock_getsize, patch("app.routers.health.RawPacketRepository") as mock_repo, ): mock_rm.is_connected = True mock_rm.connection_info = "Serial: /dev/ttyUSB0" mock_rm.is_setup_in_progress = False mock_rm.is_setup_complete = True mock_rm.connection_desired = True mock_rm.is_reconnecting = False mock_rm.device_info_loaded = False mock_getsize.return_value = 1 * 1024 * 1024 # 1 MB mock_repo.get_oldest_undecrypted = AsyncMock(return_value=None) from app.main import app client = TestClient(app) response = client.get("/api/health") assert response.status_code == 200 data = response.json() assert "oldest_undecrypted_timestamp" in data assert data["oldest_undecrypted_timestamp"] is None def test_health_handles_db_not_connected(self): """Health endpoint gracefully handles database not connected.""" from unittest.mock import AsyncMock, patch from fastapi.testclient import TestClient with ( patch("app.routers.health.radio_manager") as mock_rm, patch("app.routers.health.os.path.getsize") as mock_getsize, patch("app.routers.health.RawPacketRepository") as mock_repo, ): mock_rm.is_connected = False mock_rm.connection_info = None mock_rm.is_setup_in_progress = False mock_rm.is_setup_complete = False mock_rm.connection_desired = True mock_rm.is_reconnecting = False mock_rm.device_info_loaded = False mock_getsize.side_effect = OSError("File not found") mock_repo.get_oldest_undecrypted = AsyncMock(side_effect=RuntimeError("No DB")) from app.main import app client = TestClient(app) response = client.get("/api/health") assert response.status_code == 200 data = response.json() assert data["oldest_undecrypted_timestamp"] is None assert data["database_size_mb"] == 0.0