mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 17:53:10 +02:00
Work on some more concurrency fixes re: locks and context managers. Poking at #179.
This commit is contained in:
+17
-2
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user