Work on some more concurrency fixes re: locks and context managers. Poking at #179.

This commit is contained in:
Jack Kingsman
2026-04-16 18:04:56 -07:00
parent 4b69ec4519
commit 4783da8f3e
12 changed files with 1529 additions and 1152 deletions
+17 -2
View File
@@ -28,13 +28,28 @@ def cleanup_test_db_dir():
@pytest.fixture
async def test_db():
"""Create an in-memory test database with schema + migrations."""
from app.repository import channels, contacts, messages, raw_packets, settings
from app.repository import (
channels,
contacts,
messages,
raw_packets,
repeater_telemetry,
settings,
)
from app.repository import fanout as fanout_repo
db = Database(":memory:")
await db.connect()
submodules = [contacts, channels, messages, raw_packets, settings, fanout_repo]
submodules = [
contacts,
channels,
messages,
raw_packets,
settings,
fanout_repo,
repeater_telemetry,
]
originals = [(mod, mod.db) for mod in submodules]
for mod in submodules:
+11 -1
View File
@@ -322,7 +322,7 @@ class TestUndecryptedTextPacketStreaming:
[],
]
async def fake_execute(*_args, **_kwargs):
def fake_execute(*_args, **_kwargs):
batch = batches.pop(0)
class FakeCursor:
@@ -332,6 +332,16 @@ class TestUndecryptedTextPacketStreaming:
async def close(self):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
# aiosqlite's execute() returns a `contextmanager`-decorated
# coroutine that is both awaitable and usable as an async-with.
# Our repo code now uses `async with conn.execute(...) as cursor:`,
# so the mock just needs to return something with __aenter__/__aexit__.
return FakeCursor()
with patch.object(test_db.conn, "execute", side_effect=fake_execute):
+91 -24
View File
@@ -1,11 +1,12 @@
"""Tests for repository layer."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import patch
import pytest
from app.models import Contact, ContactUpsert
from app.repository import (
AppSettingsRepository,
ContactAdvertPathRepository,
ContactNameHistoryRepository,
ContactRepository,
@@ -613,37 +614,103 @@ class TestAppSettingsRepository:
"""Test AppSettingsRepository parsing and migration edge cases."""
@pytest.mark.asyncio
async def test_get_handles_corrupted_json_and_invalid_sort_order(self):
"""Corrupted JSON fields are recovered with safe defaults."""
mock_conn = AsyncMock()
mock_cursor = AsyncMock()
mock_cursor.fetchone = AsyncMock(
return_value={
"max_radio_contacts": 250,
"auto_decrypt_dm_on_advert": 1,
"last_message_times": "{also-not-json",
"advert_interval": None,
"last_advert_time": None,
"flood_scope": "",
"blocked_keys": "[]",
"blocked_names": "[]",
"discovery_blocked_types": "[]",
}
async def test_get_handles_corrupted_json_and_invalid_sort_order(self, test_db):
"""Corrupted JSON fields are recovered with safe defaults.
Uses the real DB so it exercises the lock-aware path. We stuff
malformed JSON directly into the row, then verify ``get()`` recovers
with defaults rather than propagating a parse error.
"""
await test_db.conn.execute(
"""
UPDATE app_settings
SET max_radio_contacts = 250,
auto_decrypt_dm_on_advert = 1,
last_message_times = '{also-not-json',
advert_interval = NULL,
last_advert_time = NULL,
flood_scope = '',
blocked_keys = '[]',
blocked_names = '[]',
discovery_blocked_types = '[]'
WHERE id = 1
"""
)
mock_conn.execute = AsyncMock(return_value=mock_cursor)
mock_db = MagicMock()
mock_db.conn = mock_conn
await test_db.conn.commit()
with patch("app.repository.settings.db", mock_db):
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
settings = await AppSettingsRepository.get()
assert settings.max_radio_contacts == 250
assert settings.last_message_times == {}
assert settings.advert_interval == 0
assert settings.last_advert_time == 0
@pytest.mark.asyncio
async def test_get_in_conn_tolerates_missing_columns(self):
"""Defend against partial migrations where columns added by later
migrations are absent from the row.
Real DBs can't produce this state (schema init + migrations always
run to the latest version on startup), but hand-rolled snapshots,
external DB tools, or interrupted migrations might. The
``KeyError``-catching branches in ``_get_in_conn`` exist specifically
to guarantee graceful degradation.
We test these directly by mocking the connection boundary with a
dict-backed row that mimics a pre-migration snapshot missing:
- ``tracked_telemetry_repeaters`` (migration 53)
- ``auto_resend_channel`` (migration 54)
- ``telemetry_interval_hours`` (migration 57)
"""
from unittest.mock import MagicMock
from app.telemetry_interval import DEFAULT_TELEMETRY_INTERVAL_HOURS
# sqlite3.Row raises KeyError for missing columns when accessed by
# name, which is what we want to simulate. We mimic that here with a
# dict-backed object whose __getitem__ raises KeyError for absent
# keys (dict.__getitem__ already does this).
class PartialRow(dict):
def keys(self): # pragma: no cover - aiosqlite.Row compat
return super().keys()
partial_row = PartialRow(
{
"max_radio_contacts": 123,
"auto_decrypt_dm_on_advert": 1,
"last_message_times": "{}",
"advert_interval": 0,
"last_advert_time": 0,
"flood_scope": "",
"blocked_keys": "[]",
"blocked_names": "[]",
"discovery_blocked_types": "[]",
# intentionally missing: tracked_telemetry_repeaters,
# auto_resend_channel, telemetry_interval_hours
}
)
class FakeCursor:
async def fetchone(self):
return partial_row
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
mock_conn = MagicMock()
mock_conn.execute = MagicMock(return_value=FakeCursor())
settings = await AppSettingsRepository._get_in_conn(mock_conn)
assert settings.max_radio_contacts == 123
# Missing-column defaults kick in:
assert settings.tracked_telemetry_repeaters == []
assert settings.auto_resend_channel is False
assert settings.telemetry_interval_hours == DEFAULT_TELEMETRY_INTERVAL_HOURS
class TestMessageRepositoryGetById:
"""Test MessageRepository.get_by_id method."""
+15 -10
View File
@@ -2,7 +2,7 @@
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
import pytest
@@ -353,13 +353,21 @@ class TestPathHashWidthStats:
@pytest.mark.asyncio
async def test_path_hash_width_scan_fetches_all_then_buckets(self, test_db):
"""Hash-width stats should fetchall() then bucket synchronously."""
"""Hash-width stats should fetchall() then bucket synchronously.
fake_rows = [{"data": b"a"}, {"data": b"b"}, {"data": b"c"}]
Uses real DB rows + a patched parser so it exercises the lock-aware
readonly path. Mocking ``conn.execute`` on the pre-refactor code no
longer reflects the actual call pattern (we use ``async with``).
"""
class FakeCursor:
async def fetchall(self):
return fake_rows
now = int(time.time())
# Seed three raw packets in the last 24h with arbitrary distinguishing bytes.
for i, data in enumerate((b"a", b"b", b"c")):
await test_db.conn.execute(
"INSERT INTO raw_packets (timestamp, data) VALUES (?, ?)",
(now - (i + 1), data),
)
await test_db.conn.commit()
def fake_parse(raw_packet: bytes):
hash_sizes = {
@@ -372,10 +380,7 @@ class TestPathHashWidthStats:
return None
return SimpleNamespace(hash_size=hash_size)
with (
patch.object(test_db.conn, "execute", new=AsyncMock(return_value=FakeCursor())),
patch("app.path_utils.parse_packet_envelope", side_effect=fake_parse),
):
with patch("app.path_utils.parse_packet_envelope", side_effect=fake_parse):
breakdown = await StatisticsRepository._path_hash_width_24h()
assert breakdown["total_packets"] == 3