mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-06 21:42:52 +02:00
Fix non-cache refresh on unfocused active threads; doc and test improvements
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user