Fix non-cache refresh on unfocused active threads; doc and test improvements

This commit is contained in:
Jack Kingsman
2026-03-04 10:16:17 -08:00
parent 1f37da8d2d
commit e0fb093612
8 changed files with 236 additions and 8 deletions

View File

@@ -10,7 +10,7 @@ If instructed to "run all tests" or "get ready for a commit" or other summative,
./scripts/all_quality.sh
```
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend in parallel. All checks must pass green.
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend sequentially. All checks must pass green.
## Overview
@@ -170,7 +170,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ └── ...
│ └── vite.config.ts
├── scripts/
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (parallelized)
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (sequential)
│ ├── collect_licenses.sh # Gather third-party license attributions
│ ├── e2e.sh # End-to-end test runner
│ └── publish.sh # Version bump, changelog, docker build & push
@@ -242,6 +242,7 @@ Key test files:
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
- `tests/test_real_crypto.py` - Real cryptographic operations
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
### Frontend (Vitest)
@@ -252,7 +253,7 @@ npm run test:run
### Before Completing Changes
**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds in parallel, catching type mismatches, breaking changes, and compilation errors.
**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds sequentially, catching type mismatches, breaking changes, and compilation errors.
## API Summary

View File

@@ -185,7 +185,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend.
Run everything at once (parallelized):
Run everything at once:
```bash
./scripts/all_quality.sh

View File

@@ -212,6 +212,8 @@ app/
- `message` — new message (channel or DM, from packet processor or send endpoints)
- `message_acked` — ACK/echo update for existing message (ack count + paths)
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
- `channel_deleted` — channel removed from database (payload: `{ key }`)
- `error` — toast notification (reconnect failure, missing private key, etc.)
- `success` — toast notification (historical decrypt complete, etc.)
@@ -271,6 +273,7 @@ tests/
├── test_config.py # Configuration validation
├── test_contacts_router.py # Contacts router endpoints
├── test_decoder.py # Packet parsing/decryption
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving

View File

@@ -27,6 +27,7 @@ frontend/src/
├── prefetch.ts # Consumes prefetched API promises started in index.html
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
├── themes.css # Color theme definitions
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── hooks/
@@ -53,7 +54,8 @@ frontend/src/
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
── radioPresets.ts # LoRa radio preset configurations
── radioPresets.ts # LoRa radio preset configurations
│ └── theme.ts # Theme switching helpers
├── components/
│ ├── StatusBar.tsx
│ ├── Sidebar.tsx
@@ -75,6 +77,7 @@ frontend/src/
│ ├── ContactStatusInfo.tsx # Contact status info component
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
@@ -85,7 +88,8 @@ frontend/src/
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ── SettingsAboutSection.tsx # Version, author, license, links
│ │ ── SettingsAboutSection.tsx # Version, author, license, links
│ │ └── ThemeSelector.tsx # Color theme picker
│ ├── repeater/
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
@@ -125,6 +129,10 @@ frontend/src/
├── sidebar.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
├── channelInfoKeyVisibility.test.tsx
├── chatHeaderKeyVisibility.test.tsx
├── searchView.test.tsx
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useRepeaterDashboard.test.ts
@@ -177,7 +185,7 @@ frontend/src/
- Auto reconnect (3s) with cleanup guard on unmount.
- Heartbeat ping every 30s.
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored).
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
## URL Hash Navigation (`utils/urlHash.ts`)
@@ -310,7 +318,7 @@ Do not rely on old class-only layout assumptions.
## Testing
Run all quality checks (backend + frontend, parallelized) from the repo root:
Run all quality checks (backend + frontend) from the repo root:
```bash
./scripts/all_quality.sh

View File

@@ -307,10 +307,20 @@ export function App() {
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
messageCache.remove(publicKey);
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
setActiveConversation(null);
}
},
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
messageCache.remove(key);
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
setActiveConversation(null);
}
},
onRawPacket: (packet: RawPacket) => {
setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS));
@@ -331,6 +341,7 @@ export function App() {
setConfig,
activeConversationRef,
hasNewerMessagesRef,
setActiveConversation,
setContacts,
setChannels,
triggerReconcile,

View File

@@ -76,6 +76,9 @@ export function addMessage(id: string, msg: Message, contentKey: string): boolea
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
}
// Promote to MRU so actively-messaged conversations aren't evicted
cache.delete(id);
cache.set(id, entry);
return true;
}

View File

@@ -239,6 +239,23 @@ describe('messageCache', () => {
expect(entry!.seenContent.has('CHAN-channel123-First contact-1700000000')).toBe(true);
});
it('promotes entry to MRU on addMessage', () => {
// Fill cache to capacity
for (let i = 0; i < MAX_CACHED_CONVERSATIONS; i++) {
messageCache.set(`conv${i}`, createEntry([createMessage({ id: i })]));
}
// addMessage to conv0 (currently LRU) should promote it
const msg = createMessage({ id: 999, text: 'Incoming WS message' });
messageCache.addMessage('conv0', msg, 'CHAN-channel123-Incoming WS message-1700000000');
// Add one more — conv1 should now be LRU and get evicted, not conv0
messageCache.set('conv_new', createEntry());
expect(messageCache.get('conv0')).toBeDefined(); // Was promoted by addMessage
expect(messageCache.get('conv1')).toBeUndefined(); // Was LRU, evicted
});
it('returns false for duplicate delivery to auto-created entry', () => {
const msg = createMessage({ id: 10, text: 'Echo' });
const contentKey = 'CHAN-channel123-Echo-1700000000';

View File

@@ -346,6 +346,191 @@ class TestHistoricalDMDecryptionPipeline:
assert "1 message" in args[1]
class TestLiveDMDecryptionPipeline:
"""Integration test: process a real DM packet through the live
process_raw_packet → _process_direct_message → try_decrypt_dm pipeline
with no crypto mocking."""
@pytest.fixture(autouse=True)
def _reset_keystore(self):
"""Reset the global keystore state between tests."""
import app.keystore as ks
orig_priv, orig_pub = ks._private_key, ks._public_key
ks._private_key = None
ks._public_key = None
yield
ks._private_key, ks._public_key = orig_priv, orig_pub
@pytest.mark.asyncio
async def test_process_dm_packet_end_to_end(self, test_db, captured_broadcasts):
"""process_raw_packet decrypts a real DM packet and stores the message
with correct text, direction, and contact attribution."""
from app.keystore import set_private_key
# Set up: client2 is "us" (receiver), client1 is the sender
set_private_key(CLIENT2_PRIVATE)
# Register client1 as a known contact
await ContactRepository.upsert(
{
"public_key": CLIENT1_PUBLIC_HEX,
"name": "Client1",
"type": 1,
}
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
from app.packet_processor import process_raw_packet
result = await process_raw_packet(raw_bytes=DM_PACKET)
# Verify process_raw_packet reports successful decryption
assert result is not None
assert result["decrypted"] is True
assert result["contact_name"] == "Client1"
assert result["message_id"] is not None
# Verify message stored in DB with correct fields
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10
)
assert len(messages) == 1
msg = messages[0]
assert msg.text == DM_PLAINTEXT
assert msg.outgoing is False # We are client2, message is FROM client1
assert msg.type == "PRIV"
# Verify a "message" broadcast was sent
msg_broadcasts = [b for b in broadcasts if b["type"] == "message"]
assert len(msg_broadcasts) == 1
assert msg_broadcasts[0]["data"]["text"] == DM_PLAINTEXT
assert msg_broadcasts[0]["data"]["outgoing"] is False
@pytest.mark.asyncio
async def test_dm_from_unknown_contact_not_decrypted(self, test_db, captured_broadcasts):
"""DM from an unknown contact (not in DB) is stored but not decrypted."""
from app.keystore import set_private_key
set_private_key(CLIENT2_PRIVATE)
# No contacts registered — client1 is unknown
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
from app.packet_processor import process_raw_packet
result = await process_raw_packet(raw_bytes=DM_PACKET)
# Raw packet is stored but not decrypted
assert result is not None
assert result["decrypted"] is False
# No messages created (can't decrypt without knowing the contact)
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10
)
assert len(messages) == 0
@pytest.mark.asyncio
async def test_dm_without_private_key_not_decrypted(self, test_db, captured_broadcasts):
"""Without a private key in the keystore, DMs are stored but not decrypted."""
# Don't call set_private_key — keystore is empty
await ContactRepository.upsert(
{
"public_key": CLIENT1_PUBLIC_HEX,
"name": "Client1",
"type": 1,
}
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
from app.packet_processor import process_raw_packet
result = await process_raw_packet(raw_bytes=DM_PACKET)
assert result is not None
assert result["decrypted"] is False
@pytest.mark.asyncio
async def test_dm_duplicate_packet_deduplicates(self, test_db, captured_broadcasts):
"""Processing the same DM packet twice doesn't create duplicate messages."""
from app.keystore import set_private_key
set_private_key(CLIENT2_PRIVATE)
await ContactRepository.upsert(
{
"public_key": CLIENT1_PUBLIC_HEX,
"name": "Client1",
"type": 1,
}
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
from app.packet_processor import process_raw_packet
result1 = await process_raw_packet(raw_bytes=DM_PACKET)
await process_raw_packet(raw_bytes=DM_PACKET)
# First processing succeeds
assert result1["decrypted"] is True
assert result1["message_id"] is not None
# Only one message stored (dedup via unique constraint)
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10
)
assert len(messages) == 1
@pytest.mark.asyncio
async def test_historical_then_live_deduplicates(self, test_db, captured_broadcasts):
"""A DM decrypted historically and then received live doesn't duplicate."""
from app.keystore import set_private_key
from app.packet_processor import process_raw_packet, run_historical_dm_decryption
set_private_key(CLIENT2_PRIVATE)
await ContactRepository.upsert(
{
"public_key": CLIENT1_PUBLIC_HEX,
"name": "Client1",
"type": 1,
}
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
# First: store packet undecrypted, then run historical decryption
await RawPacketRepository.create(DM_PACKET, 1700000000)
with patch("app.websocket.broadcast_success"):
await run_historical_dm_decryption(
private_key_bytes=CLIENT2_PRIVATE,
contact_public_key_bytes=CLIENT1_PUBLIC,
contact_public_key_hex=CLIENT1_PUBLIC_HEX,
display_name="Client1",
)
# Then: same packet arrives again via live pipeline
await process_raw_packet(raw_bytes=DM_PACKET)
# Only one message stored despite both paths processing it
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10
)
assert len(messages) == 1
assert messages[0].text == DM_PLAINTEXT
class TestHistoricalChannelDecryptionPipeline:
"""Integration test: store a real channel packet, process it through
the channel message pipeline, verify correct message in DB."""