"""Tests for the contacts router. Verifies the contact CRUD endpoints, sync, mark-read, delete, and add/remove from radio operations. Uses httpx.AsyncClient with real in-memory SQLite database. """ from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from meshcore import EventType from app.database import Database from app.repository import ContactRepository, MessageRepository # Sample 64-char hex public keys for testing KEY_A = "aa" * 32 # aaaa...aa KEY_B = "bb" * 32 # bbbb...bb KEY_C = "cc" * 32 # cccc...cc @asynccontextmanager async def _noop_radio_operation(*_args, **_kwargs): yield @pytest.fixture async def test_db(): """Create an in-memory test database with schema + migrations.""" import app.repository as repo_module db = Database(":memory:") await db.connect() original_db = repo_module.db repo_module.db = db try: yield db finally: repo_module.db = original_db await db.disconnect() async def _insert_contact(public_key=KEY_A, name="Alice", on_radio=False, **overrides): """Insert a contact into the test database.""" data = { "public_key": public_key, "name": name, "type": 0, "flags": 0, "last_path": None, "last_path_len": -1, "last_advert": None, "lat": None, "lon": None, "last_seen": None, "on_radio": on_radio, "last_contacted": None, } data.update(overrides) await ContactRepository.upsert(data) @pytest.fixture def client(): """Create an httpx AsyncClient for testing the app.""" from app.main import app transport = httpx.ASGITransport(app=app) return httpx.AsyncClient(transport=transport, base_url="http://test") class TestListContacts: """Test GET /api/contacts.""" @pytest.mark.asyncio async def test_list_returns_contacts(self, test_db, client): await _insert_contact(KEY_A, "Alice") await _insert_contact(KEY_B, "Bob") response = await client.get("/api/contacts") assert response.status_code == 200 data = response.json() assert len(data) == 2 keys = {d["public_key"] for d in data} assert KEY_A in keys assert KEY_B in keys @pytest.mark.asyncio async def test_list_pagination_params(self, test_db, client): # Insert 3 contacts await _insert_contact(KEY_A, "Alice") await _insert_contact(KEY_B, "Bob") await _insert_contact(KEY_C, "Carol") response = await client.get("/api/contacts?limit=2&offset=0") assert response.status_code == 200 data = response.json() assert len(data) == 2 class TestCreateContact: """Test POST /api/contacts.""" @pytest.mark.asyncio async def test_create_new_contact(self, test_db, client): response = await client.post( "/api/contacts", json={"public_key": KEY_A, "name": "NewContact"}, ) assert response.status_code == 200 data = response.json() assert data["public_key"] == KEY_A assert data["name"] == "NewContact" # Verify in DB contact = await ContactRepository.get_by_key(KEY_A) assert contact is not None assert contact.name == "NewContact" @pytest.mark.asyncio async def test_create_invalid_hex(self, test_db, client): """Non-hex public key returns 400.""" response = await client.post( "/api/contacts", json={"public_key": "zz" * 32, "name": "Bad"}, ) assert response.status_code == 400 assert "hex" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_create_short_key_rejected(self, test_db, client): """Key shorter than 64 chars is rejected by pydantic validation.""" response = await client.post( "/api/contacts", json={"public_key": "aa" * 16, "name": "Short"}, ) assert response.status_code == 422 @pytest.mark.asyncio async def test_create_existing_updates_name(self, test_db, client): """Creating a contact that exists updates the name.""" await _insert_contact(KEY_A, "OldName") response = await client.post( "/api/contacts", json={"public_key": KEY_A, "name": "NewName"}, ) assert response.status_code == 200 # Verify name was updated in DB contact = await ContactRepository.get_by_key(KEY_A) assert contact.name == "NewName" class TestGetContact: """Test GET /api/contacts/{public_key}.""" @pytest.mark.asyncio async def test_get_existing(self, test_db, client): await _insert_contact(KEY_A, "Alice") response = await client.get(f"/api/contacts/{KEY_A}") assert response.status_code == 200 assert response.json()["name"] == "Alice" @pytest.mark.asyncio async def test_get_not_found(self, test_db, client): response = await client.get(f"/api/contacts/{KEY_A}") assert response.status_code == 404 @pytest.mark.asyncio async def test_get_ambiguous_prefix_returns_409(self, test_db, client): # Insert two contacts that share a prefix await _insert_contact("abcd12" + "00" * 29, "ContactA") await _insert_contact("abcd12" + "ff" * 29, "ContactB") response = await client.get("/api/contacts/abcd12") assert response.status_code == 409 assert "ambiguous" in response.json()["detail"].lower() class TestMarkRead: """Test POST /api/contacts/{public_key}/mark-read.""" @pytest.mark.asyncio async def test_mark_read_updates_timestamp(self, test_db, client): await _insert_contact(KEY_A) response = await client.post(f"/api/contacts/{KEY_A}/mark-read") assert response.status_code == 200 assert response.json()["status"] == "ok" # Verify last_read_at was set in DB contact = await ContactRepository.get_by_key(KEY_A) assert contact.last_read_at is not None @pytest.mark.asyncio async def test_mark_read_not_found(self, test_db, client): response = await client.post(f"/api/contacts/{KEY_A}/mark-read") assert response.status_code == 404 class TestDeleteContact: """Test DELETE /api/contacts/{public_key}.""" @pytest.mark.asyncio async def test_delete_existing(self, test_db, client): await _insert_contact(KEY_A) with patch("app.routers.contacts.radio_manager") as mock_rm: mock_rm.is_connected = False mock_rm.meshcore = None mock_rm.radio_operation = _noop_radio_operation response = await client.delete(f"/api/contacts/{KEY_A}") assert response.status_code == 200 assert response.json()["status"] == "ok" # Verify deleted from DB contact = await ContactRepository.get_by_key(KEY_A) assert contact is None @pytest.mark.asyncio async def test_delete_not_found(self, test_db, client): response = await client.delete(f"/api/contacts/{KEY_A}") assert response.status_code == 404 @pytest.mark.asyncio async def test_delete_removes_from_radio_if_connected(self, test_db, client): """When radio is connected and contact is on radio, remove it first.""" await _insert_contact(KEY_A, on_radio=True) mock_radio_contact = MagicMock() mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix = MagicMock(return_value=mock_radio_contact) mock_mc.commands.remove_contact = AsyncMock() with patch("app.routers.contacts.radio_manager") as mock_rm: mock_rm.is_connected = True mock_rm.meshcore = mock_mc mock_rm.radio_operation = _noop_radio_operation response = await client.delete(f"/api/contacts/{KEY_A}") assert response.status_code == 200 mock_mc.commands.remove_contact.assert_called_once_with(mock_radio_contact) class TestSyncContacts: """Test POST /api/contacts/sync.""" @pytest.mark.asyncio async def test_sync_from_radio(self, test_db, client): mock_mc = MagicMock() mock_result = MagicMock() mock_result.type = EventType.OK mock_result.payload = { KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}, KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0}, } mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) with patch("app.dependencies.radio_manager") as mock_dep_rm: mock_dep_rm.is_connected = True mock_dep_rm.meshcore = mock_mc response = await client.post("/api/contacts/sync") assert response.status_code == 200 assert response.json()["synced"] == 2 # Verify contacts are in real DB alice = await ContactRepository.get_by_key(KEY_A) assert alice is not None assert alice.name == "Alice" @pytest.mark.asyncio async def test_sync_requires_connection(self, test_db, client): with patch("app.dependencies.radio_manager") as mock_rm: mock_rm.is_connected = False mock_rm.meshcore = None response = await client.post("/api/contacts/sync") assert response.status_code == 503 @pytest.mark.asyncio async def test_sync_claims_prefix_messages(self, test_db, client): """Syncing contacts promotes prefix-stored DM messages to the full key.""" await MessageRepository.create( msg_type="PRIV", text="hello from prefix", received_at=1700000000, conversation_key=KEY_A[:12], sender_timestamp=1700000000, ) mock_mc = MagicMock() mock_result = MagicMock() mock_result.type = EventType.OK mock_result.payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}} mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) with patch("app.dependencies.radio_manager") as mock_dep_rm: mock_dep_rm.is_connected = True mock_dep_rm.meshcore = mock_mc response = await client.post("/api/contacts/sync") assert response.status_code == 200 assert response.json()["synced"] == 1 messages = await MessageRepository.get_all(conversation_key=KEY_A) assert len(messages) == 1 assert messages[0].conversation_key == KEY_A.lower() class TestAddRemoveRadio: """Test add-to-radio and remove-from-radio endpoints.""" @pytest.mark.asyncio async def test_add_to_radio(self, test_db, client): await _insert_contact(KEY_A) mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None) # Not on radio mock_result = MagicMock() mock_result.type = EventType.OK mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) with patch("app.dependencies.radio_manager") as mock_dep_rm: mock_dep_rm.is_connected = True mock_dep_rm.meshcore = mock_mc response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") assert response.status_code == 200 mock_mc.commands.add_contact.assert_called_once() # Verify on_radio flag updated in DB contact = await ContactRepository.get_by_key(KEY_A) assert contact.on_radio is True @pytest.mark.asyncio async def test_add_already_on_radio(self, test_db, client): """Adding a contact already on radio returns ok without calling add_contact.""" await _insert_contact(KEY_A, on_radio=True) mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # On radio with patch("app.dependencies.radio_manager") as mock_dep_rm: mock_dep_rm.is_connected = True mock_dep_rm.meshcore = mock_mc response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") assert response.status_code == 200 assert "already" in response.json()["message"].lower() @pytest.mark.asyncio async def test_remove_from_radio(self, test_db, client): await _insert_contact(KEY_A, on_radio=True) mock_radio_contact = MagicMock() mock_mc = MagicMock() mock_mc.get_contact_by_key_prefix = MagicMock(return_value=mock_radio_contact) mock_result = MagicMock() mock_result.type = EventType.OK mock_mc.commands.remove_contact = AsyncMock(return_value=mock_result) with patch("app.dependencies.radio_manager") as mock_dep_rm: mock_dep_rm.is_connected = True mock_dep_rm.meshcore = mock_mc response = await client.post(f"/api/contacts/{KEY_A}/remove-from-radio") assert response.status_code == 200 mock_mc.commands.remove_contact.assert_called_once_with(mock_radio_contact) # Verify on_radio flag updated in DB contact = await ContactRepository.get_by_key(KEY_A) assert contact.on_radio is False @pytest.mark.asyncio async def test_add_requires_connection(self, test_db, client): with patch("app.dependencies.radio_manager") as mock_rm: mock_rm.is_connected = False mock_rm.meshcore = None response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") assert response.status_code == 503 @pytest.mark.asyncio async def test_remove_not_found(self, test_db, client): mock_mc = MagicMock() with patch("app.dependencies.radio_manager") as mock_dep_rm: mock_dep_rm.is_connected = True mock_dep_rm.meshcore = mock_mc response = await client.post(f"/api/contacts/{KEY_A}/remove-from-radio") assert response.status_code == 404