Contact info pane

This commit is contained in:
Jack Kingsman
2026-02-27 13:38:53 -08:00
parent 24166e92e8
commit b91b2d5d7b
28 changed files with 1624 additions and 162 deletions
+159 -8
View File
@@ -15,7 +15,7 @@ from meshcore import EventType
from app.database import Database
from app.radio import radio_manager
from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository
from app.repository import ContactAdvertPathRepository, ContactRepository, MessageRepository
# Sample 64-char hex public keys for testing
KEY_A = "aa" * 32 # aaaa...aa
@@ -215,15 +215,15 @@ class TestAdvertPaths:
async def test_list_repeater_advert_paths(self, test_db, client):
repeater_key = KEY_A
await _insert_contact(repeater_key, "R1", type=2)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "1122", 1000)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "3344", 1010)
await ContactAdvertPathRepository.record_observation(repeater_key, "1122", 1000)
await ContactAdvertPathRepository.record_observation(repeater_key, "3344", 1010)
response = await client.get("/api/contacts/repeaters/advert-paths?limit_per_repeater=1")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["repeater_key"] == repeater_key
assert data[0]["public_key"] == repeater_key
assert len(data[0]["paths"]) == 1
assert data[0]["paths"][0]["path"] == "3344"
assert data[0]["paths"][0]["next_hop"] == "33"
@@ -232,7 +232,7 @@ class TestAdvertPaths:
async def test_get_contact_advert_paths_for_repeater(self, test_db, client):
repeater_key = KEY_A
await _insert_contact(repeater_key, "R1", type=2)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "", 1000)
await ContactAdvertPathRepository.record_observation(repeater_key, "", 1000)
response = await client.get(f"/api/contacts/{repeater_key}/advert-paths")
@@ -243,13 +243,164 @@ class TestAdvertPaths:
assert data[0]["next_hop"] is None
@pytest.mark.asyncio
async def test_get_contact_advert_paths_rejects_non_repeater(self, test_db, client):
async def test_get_contact_advert_paths_works_for_non_repeater(self, test_db, client):
await _insert_contact(KEY_A, "Alice", type=1)
response = await client.get(f"/api/contacts/{KEY_A}/advert-paths")
assert response.status_code == 400
assert "not a repeater" in response.json()["detail"].lower()
assert response.status_code == 200
assert response.json() == []
class TestContactDetail:
"""Test GET /api/contacts/{public_key}/detail."""
@pytest.mark.asyncio
async def test_detail_returns_full_profile(self, test_db, client):
"""Happy path: contact with DMs, channel messages, name history, advert paths."""
await _insert_contact(KEY_A, "Alice", type=1)
# Add some DMs
await MessageRepository.create(
msg_type="PRIV",
text="hi",
conversation_key=KEY_A,
sender_timestamp=1000,
received_at=1000,
sender_key=KEY_A,
)
await MessageRepository.create(
msg_type="PRIV",
text="hello",
conversation_key=KEY_A,
sender_timestamp=1001,
received_at=1001,
outgoing=True,
)
# Add a channel message attributed to this contact
from app.repository import ContactNameHistoryRepository
await MessageRepository.create(
msg_type="CHAN",
text="Alice: yo",
conversation_key="CHAN_KEY_0" * 2,
sender_timestamp=1002,
received_at=1002,
sender_name="Alice",
sender_key=KEY_A,
)
# Record name history
await ContactNameHistoryRepository.record_name(KEY_A, "Alice", 1000)
await ContactNameHistoryRepository.record_name(KEY_A, "AliceOld", 500)
# Record advert paths
await ContactAdvertPathRepository.record_observation(KEY_A, "1122", 1000)
await ContactAdvertPathRepository.record_observation(KEY_A, "", 900)
response = await client.get(f"/api/contacts/{KEY_A}/detail")
assert response.status_code == 200
data = response.json()
assert data["contact"]["public_key"] == KEY_A
assert data["dm_message_count"] == 2
assert data["channel_message_count"] == 1
assert len(data["name_history"]) == 2
assert data["name_history"][0]["name"] == "Alice" # most recent first
assert len(data["advert_paths"]) == 2
assert len(data["most_active_rooms"]) == 1
@pytest.mark.asyncio
async def test_detail_contact_not_found(self, test_db, client):
response = await client.get(f"/api/contacts/{KEY_A}/detail")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_detail_with_no_activity(self, test_db, client):
"""Contact with no messages or paths returns zero counts and empty lists."""
await _insert_contact(KEY_A, "Alice")
response = await client.get(f"/api/contacts/{KEY_A}/detail")
assert response.status_code == 200
data = response.json()
assert data["dm_message_count"] == 0
assert data["channel_message_count"] == 0
assert data["most_active_rooms"] == []
assert data["advert_paths"] == []
assert data["advert_frequency"] is None
assert data["nearest_repeaters"] == []
@pytest.mark.asyncio
async def test_detail_nearest_repeaters_resolved(self, test_db, client):
"""Nearest repeaters are resolved from first-hop prefixes in advert paths."""
await _insert_contact(KEY_A, "Alice", type=1)
# Create a repeater whose key starts with "bb"
await _insert_contact(KEY_B, "Relay1", type=2)
# Record advert paths that go through KEY_B's prefix
await ContactAdvertPathRepository.record_observation(KEY_A, "bb1122", 1000)
await ContactAdvertPathRepository.record_observation(KEY_A, "bb3344", 1010)
response = await client.get(f"/api/contacts/{KEY_A}/detail")
assert response.status_code == 200
data = response.json()
assert len(data["nearest_repeaters"]) == 1
repeater = data["nearest_repeaters"][0]
assert repeater["public_key"] == KEY_B
assert repeater["name"] == "Relay1"
assert repeater["heard_count"] == 2
@pytest.mark.asyncio
async def test_detail_advert_frequency_computed(self, test_db, client):
"""Advert frequency is computed from path observations over time span."""
await _insert_contact(KEY_A, "Alice")
# 10 observations over 1 hour (3600s)
for i in range(10):
path_hex = f"{i:02x}" * 2 # unique paths to avoid upsert
await ContactAdvertPathRepository.record_observation(KEY_A, path_hex, 1000 + i * 360)
response = await client.get(f"/api/contacts/{KEY_A}/detail")
assert response.status_code == 200
data = response.json()
# 10 observations / (3240s / 3600) ≈ 11.11/hr
assert data["advert_frequency"] is not None
assert data["advert_frequency"] > 0
class TestDeleteContactCascade:
"""Test that contact delete cleans up related tables."""
@pytest.mark.asyncio
async def test_delete_removes_name_history_and_advert_paths(self, test_db, client):
await _insert_contact(KEY_A, "Alice")
from app.repository import ContactNameHistoryRepository
await ContactNameHistoryRepository.record_name(KEY_A, "Alice", 1000)
await ContactAdvertPathRepository.record_observation(KEY_A, "1122", 1000)
# Verify data exists
assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 1
assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 1
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
# Verify related data cleaned up
assert len(await ContactNameHistoryRepository.get_history(KEY_A)) == 0
assert len(await ContactAdvertPathRepository.get_recent_for_contact(KEY_A)) == 0
class TestMarkRead:
+16 -16
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 22 # All migrations run
assert await get_version(conn) == 22
assert applied == 27 # All migrations run
assert await get_version(conn) == 27
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -183,9 +183,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 22 # All migrations run
assert applied1 == 27 # All migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 22
assert await get_version(conn) == 27
finally:
await conn.close()
@@ -246,8 +246,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
assert applied == 22
assert await get_version(conn) == 22
assert applied == 27
assert await get_version(conn) == 27
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14-22 which also run)
# Run migration 13 (plus 14-27 which also run)
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 22
assert applied == 15
assert await get_version(conn) == 27
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
@@ -497,7 +497,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 22
assert await get_version(conn) == 27
# Verify autoindex is gone
cursor = await conn.execute(
@@ -571,8 +571,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 5 # Migrations 18+19+20+21+22 run (18+19 skip internally)
assert await get_version(conn) == 22
assert applied == 10 # Migrations 18-27 run (18+19 skip internally)
assert await get_version(conn) == 27
finally:
await conn.close()
@@ -644,7 +644,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 22
assert await get_version(conn) == 27
# Verify autoindex is gone
cursor = await conn.execute(
@@ -710,8 +710,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
assert applied == 3 # Migrations 20+21+22
assert await get_version(conn) == 22
assert applied == 8 # Migrations 20-27
assert await get_version(conn) == 27
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -741,7 +741,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
assert applied == 2 # Migrations 21+22 still run
assert applied == 7 # Migrations 21-27 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
+187 -23
View File
@@ -5,7 +5,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.database import Database
from app.repository import ContactRepository, MessageRepository, RepeaterAdvertPathRepository
from app.repository import (
ContactAdvertPathRepository,
ContactNameHistoryRepository,
ContactRepository,
MessageRepository,
)
@pytest.fixture
@@ -267,18 +272,18 @@ class TestMessageRepositoryGetByContent:
assert result.paths is None
class TestRepeaterAdvertPathRepository:
"""Test storing and retrieving recent unique repeater advert paths."""
class TestContactAdvertPathRepository:
"""Test storing and retrieving recent unique advert paths."""
@pytest.mark.asyncio
async def test_record_observation_upserts_and_tracks_count(self, test_db):
repeater_key = "aa" * 32
await ContactRepository.upsert({"public_key": repeater_key, "name": "R1", "type": 2})
await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1000)
await RepeaterAdvertPathRepository.record_observation(repeater_key, "112233", 1010)
await ContactAdvertPathRepository.record_observation(repeater_key, "112233", 1000)
await ContactAdvertPathRepository.record_observation(repeater_key, "112233", 1010)
paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10)
paths = await ContactAdvertPathRepository.get_recent_for_contact(repeater_key, limit=10)
assert len(paths) == 1
assert paths[0].path == "112233"
assert paths[0].path_len == 3
@@ -292,17 +297,11 @@ class TestRepeaterAdvertPathRepository:
repeater_key = "bb" * 32
await ContactRepository.upsert({"public_key": repeater_key, "name": "R2", "type": 2})
await RepeaterAdvertPathRepository.record_observation(
repeater_key, "aa", 1000, max_paths_per_repeater=2
)
await RepeaterAdvertPathRepository.record_observation(
repeater_key, "bb", 1001, max_paths_per_repeater=2
)
await RepeaterAdvertPathRepository.record_observation(
repeater_key, "cc", 1002, max_paths_per_repeater=2
)
await ContactAdvertPathRepository.record_observation(repeater_key, "aa", 1000, max_paths=2)
await ContactAdvertPathRepository.record_observation(repeater_key, "bb", 1001, max_paths=2)
await ContactAdvertPathRepository.record_observation(repeater_key, "cc", 1002, max_paths=2)
paths = await RepeaterAdvertPathRepository.get_recent_for_repeater(repeater_key, limit=10)
paths = await ContactAdvertPathRepository.get_recent_for_contact(repeater_key, limit=10)
assert [p.path for p in paths] == ["cc", "bb"]
@pytest.mark.asyncio
@@ -312,14 +311,12 @@ class TestRepeaterAdvertPathRepository:
await ContactRepository.upsert({"public_key": repeater_a, "name": "RA", "type": 2})
await ContactRepository.upsert({"public_key": repeater_b, "name": "RB", "type": 2})
await RepeaterAdvertPathRepository.record_observation(repeater_a, "01", 1000)
await RepeaterAdvertPathRepository.record_observation(repeater_a, "02", 1001)
await RepeaterAdvertPathRepository.record_observation(repeater_b, "", 1002)
await ContactAdvertPathRepository.record_observation(repeater_a, "01", 1000)
await ContactAdvertPathRepository.record_observation(repeater_a, "02", 1001)
await ContactAdvertPathRepository.record_observation(repeater_b, "", 1002)
grouped = await RepeaterAdvertPathRepository.get_recent_for_all_repeaters(
limit_per_repeater=1
)
by_key = {item.repeater_key: item.paths for item in grouped}
grouped = await ContactAdvertPathRepository.get_recent_for_all_contacts(limit_per_contact=1)
by_key = {item.public_key: item.paths for item in grouped}
assert repeater_a in by_key
assert repeater_b in by_key
@@ -329,6 +326,173 @@ class TestRepeaterAdvertPathRepository:
assert by_key[repeater_b][0].next_hop is None
class TestContactNameHistoryRepository:
"""Test contact name history tracking."""
@pytest.mark.asyncio
async def test_record_and_retrieve_name_history(self, test_db):
key = "aa" * 32
await ContactRepository.upsert({"public_key": key, "name": "Alice", "type": 1})
await ContactNameHistoryRepository.record_name(key, "Alice", 1000)
await ContactNameHistoryRepository.record_name(key, "AliceV2", 2000)
history = await ContactNameHistoryRepository.get_history(key)
assert len(history) == 2
assert history[0].name == "AliceV2" # most recent first
assert history[1].name == "Alice"
@pytest.mark.asyncio
async def test_record_name_upserts_last_seen(self, test_db):
key = "bb" * 32
await ContactRepository.upsert({"public_key": key, "name": "Bob", "type": 1})
await ContactNameHistoryRepository.record_name(key, "Bob", 1000)
await ContactNameHistoryRepository.record_name(key, "Bob", 2000)
history = await ContactNameHistoryRepository.get_history(key)
assert len(history) == 1
assert history[0].first_seen == 1000
assert history[0].last_seen == 2000
class TestMessageRepositoryContactStats:
"""Test per-contact message counting methods."""
@pytest.mark.asyncio
async def test_count_dm_messages(self, test_db):
key = "aa" * 32
await ContactRepository.upsert({"public_key": key, "name": "Alice", "type": 1})
await MessageRepository.create(
msg_type="PRIV",
text="hi",
conversation_key=key,
sender_timestamp=1000,
received_at=1000,
sender_key=key,
)
await MessageRepository.create(
msg_type="PRIV",
text="hello back",
conversation_key=key,
sender_timestamp=1001,
received_at=1001,
outgoing=True,
)
# Different contact's DM should not be counted
other_key = "bb" * 32
await MessageRepository.create(
msg_type="PRIV",
text="hey",
conversation_key=other_key,
sender_timestamp=1002,
received_at=1002,
sender_key=other_key,
)
count = await MessageRepository.count_dm_messages(key)
assert count == 2
@pytest.mark.asyncio
async def test_count_channel_messages_by_sender(self, test_db):
key = "aa" * 32
chan_key = "CC" * 16
await MessageRepository.create(
msg_type="CHAN",
text="Alice: msg1",
conversation_key=chan_key,
sender_timestamp=1000,
received_at=1000,
sender_name="Alice",
sender_key=key,
)
await MessageRepository.create(
msg_type="CHAN",
text="Alice: msg2",
conversation_key=chan_key,
sender_timestamp=1001,
received_at=1001,
sender_name="Alice",
sender_key=key,
)
count = await MessageRepository.count_channel_messages_by_sender(key)
assert count == 2
@pytest.mark.asyncio
async def test_get_most_active_rooms(self, test_db):
key = "aa" * 32
chan_a = "AA" * 16
chan_b = "BB" * 16
from app.repository import ChannelRepository
await ChannelRepository.upsert(chan_a, "General")
await ChannelRepository.upsert(chan_b, "Random")
# 3 messages in chan_a, 1 in chan_b
for i in range(3):
await MessageRepository.create(
msg_type="CHAN",
text=f"Alice: msg{i}",
conversation_key=chan_a,
sender_timestamp=1000 + i,
received_at=1000 + i,
sender_name="Alice",
sender_key=key,
)
await MessageRepository.create(
msg_type="CHAN",
text="Alice: hi",
conversation_key=chan_b,
sender_timestamp=2000,
received_at=2000,
sender_name="Alice",
sender_key=key,
)
rooms = await MessageRepository.get_most_active_rooms(key, limit=5)
assert len(rooms) == 2
assert rooms[0][0] == chan_a # most active first
assert rooms[0][1] == "General"
assert rooms[0][2] == 3
assert rooms[1][2] == 1
class TestContactRepositoryResolvePrefixes:
"""Test batch prefix resolution."""
@pytest.mark.asyncio
async def test_resolves_unique_prefixes(self, test_db):
key_a = "aa" * 32
key_b = "bb" * 32
await ContactRepository.upsert({"public_key": key_a, "name": "Alice", "type": 1})
await ContactRepository.upsert({"public_key": key_b, "name": "Bob", "type": 1})
result = await ContactRepository.resolve_prefixes(["aa", "bb"])
assert "aa" in result
assert "bb" in result
assert result["aa"].public_key == key_a
assert result["bb"].public_key == key_b
@pytest.mark.asyncio
async def test_omits_ambiguous_prefixes(self, test_db):
key_a = "aa" + "11" * 31
key_b = "aa" + "22" * 31
await ContactRepository.upsert({"public_key": key_a, "name": "A1", "type": 1})
await ContactRepository.upsert({"public_key": key_b, "name": "A2", "type": 1})
result = await ContactRepository.resolve_prefixes(["aa"])
assert "aa" not in result # ambiguous — two matches
@pytest.mark.asyncio
async def test_empty_prefixes_returns_empty(self, test_db):
result = await ContactRepository.resolve_prefixes([])
assert result == {}
class TestAppSettingsRepository:
"""Test AppSettingsRepository parsing and migration edge cases."""