mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
/**
|
|
* Integration tests for message deduplication and content key contracts.
|
|
*
|
|
* These tests verify that the real messageCache and getMessageContentKey
|
|
* functions work correctly with realistic WebSocket event data from fixtures.
|
|
*
|
|
* The fixtures in fixtures/websocket_events.json define the contract
|
|
* between backend and frontend - both sides test against the same data.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import fixtures from './fixtures/websocket_events.json';
|
|
import { getMessageContentKey } from '../hooks/useConversationMessages';
|
|
import { getStateKey } from '../utils/conversationState';
|
|
import { mergeContactIntoList } from '../utils/contactMerge';
|
|
import * as messageCache from '../messageCache';
|
|
import type { Contact, Message } from '../types';
|
|
|
|
/**
|
|
* Minimal state for testing message dedup and unread logic.
|
|
* Uses real messageCache.addMessage and real getMessageContentKey.
|
|
*/
|
|
interface MockState {
|
|
messages: Message[];
|
|
unreadCounts: Record<string, number>;
|
|
lastMessageTimes: Record<string, number>;
|
|
seenActiveContent: Set<string>;
|
|
}
|
|
|
|
function createMockState(): MockState {
|
|
return {
|
|
messages: [],
|
|
unreadCounts: {},
|
|
lastMessageTimes: {},
|
|
seenActiveContent: new Set(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Simulate the message handling path from App.tsx.
|
|
* Uses real getMessageContentKey and real messageCache.addMessage for dedup.
|
|
*/
|
|
function handleMessageEvent(
|
|
state: MockState,
|
|
msg: Message,
|
|
activeConversationKey: string | null
|
|
): { added: boolean; unreadIncremented: boolean } {
|
|
const contentKey = getMessageContentKey(msg);
|
|
let added = false;
|
|
let unreadIncremented = false;
|
|
|
|
const isForActiveConversation =
|
|
activeConversationKey !== null && msg.conversation_key === activeConversationKey;
|
|
|
|
if (isForActiveConversation) {
|
|
if (!state.seenActiveContent.has(contentKey)) {
|
|
state.seenActiveContent.add(contentKey);
|
|
state.messages.push(msg);
|
|
added = true;
|
|
}
|
|
}
|
|
|
|
const stateKey =
|
|
msg.type === 'CHAN'
|
|
? getStateKey('channel', msg.conversation_key)
|
|
: getStateKey('contact', msg.conversation_key);
|
|
|
|
state.lastMessageTimes[stateKey] = msg.received_at;
|
|
|
|
if (!isForActiveConversation) {
|
|
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
|
|
if (!msg.outgoing && isNew) {
|
|
state.unreadCounts[stateKey] = (state.unreadCounts[stateKey] || 0) + 1;
|
|
unreadIncremented = true;
|
|
}
|
|
}
|
|
|
|
return { added, unreadIncremented };
|
|
}
|
|
|
|
// Clear messageCache between tests to avoid cross-test contamination
|
|
beforeEach(() => {
|
|
messageCache.clear();
|
|
});
|
|
|
|
describe('Integration: Channel Message Events', () => {
|
|
const fixture = fixtures.channel_message;
|
|
|
|
it('adds message to list when conversation is active', () => {
|
|
const state = createMockState();
|
|
const msg = fixture.expected_ws_event.data as unknown as Message;
|
|
msg.id = 1;
|
|
msg.received_at = 1700000000;
|
|
|
|
const result = handleMessageEvent(state, msg, msg.conversation_key);
|
|
|
|
expect(result.added).toBe(true);
|
|
expect(state.messages).toHaveLength(1);
|
|
expect(state.messages[0].text).toContain('Flightless🥝');
|
|
});
|
|
|
|
it('increments unread count when conversation is not active', () => {
|
|
const state = createMockState();
|
|
const msg = fixture.expected_ws_event.data as unknown as Message;
|
|
msg.id = 1;
|
|
msg.received_at = 1700000000;
|
|
|
|
const result = handleMessageEvent(state, msg, 'different_conversation');
|
|
|
|
expect(result.unreadIncremented).toBe(true);
|
|
const stateKey = getStateKey('channel', msg.conversation_key);
|
|
expect(state.unreadCounts[stateKey]).toBe(1);
|
|
});
|
|
|
|
it('updates lastMessageTimes for sidebar sorting', () => {
|
|
const state = createMockState();
|
|
const msg = fixture.expected_ws_event.data as unknown as Message;
|
|
msg.id = 1;
|
|
msg.received_at = 1700000000;
|
|
|
|
handleMessageEvent(state, msg, null);
|
|
|
|
const stateKey = getStateKey('channel', msg.conversation_key);
|
|
expect(state.lastMessageTimes[stateKey]).toBe(1700000000);
|
|
});
|
|
|
|
it('does not increment unread for outgoing messages', () => {
|
|
const state = createMockState();
|
|
const msg = { ...fixture.expected_ws_event.data, outgoing: true } as unknown as Message;
|
|
msg.id = 1;
|
|
msg.received_at = 1700000000;
|
|
|
|
const result = handleMessageEvent(state, msg, 'different_conversation');
|
|
|
|
expect(result.unreadIncremented).toBe(false);
|
|
const stateKey = getStateKey('channel', msg.conversation_key);
|
|
expect(state.unreadCounts[stateKey]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Integration: Duplicate Message Handling', () => {
|
|
it('deduplicates messages by content when adding to list', () => {
|
|
const state = createMockState();
|
|
const msgData = fixtures.channel_message.expected_ws_event.data;
|
|
const msg1 = { ...msgData, id: 1, received_at: 1700000000 } as unknown as Message;
|
|
const msg2 = { ...msgData, id: 2, received_at: 1700000001 } as unknown as Message;
|
|
|
|
const result1 = handleMessageEvent(state, msg1, msg1.conversation_key);
|
|
const result2 = handleMessageEvent(state, msg2, msg2.conversation_key);
|
|
|
|
expect(result1.added).toBe(true);
|
|
expect(result2.added).toBe(false); // Deduplicated
|
|
expect(state.messages).toHaveLength(1);
|
|
});
|
|
|
|
it('deduplicates unread increments by content', () => {
|
|
const state = createMockState();
|
|
const msgData = fixtures.channel_message.expected_ws_event.data;
|
|
const msg1 = { ...msgData, id: 1, received_at: 1700000000 } as unknown as Message;
|
|
const msg2 = { ...msgData, id: 2, received_at: 1700000001 } as unknown as Message;
|
|
|
|
const result1 = handleMessageEvent(state, msg1, 'other_conversation');
|
|
const result2 = handleMessageEvent(state, msg2, 'other_conversation');
|
|
|
|
expect(result1.unreadIncremented).toBe(true);
|
|
expect(result2.unreadIncremented).toBe(false); // Deduplicated
|
|
|
|
const stateKey = getStateKey('channel', msg1.conversation_key);
|
|
expect(state.unreadCounts[stateKey]).toBe(1); // Only incremented once
|
|
});
|
|
});
|
|
|
|
describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regression)', () => {
|
|
it('does not increment unread when a mesh echo arrives after many unique messages', () => {
|
|
const state = createMockState();
|
|
const convKey = 'channel_busy';
|
|
|
|
// Deliver 1001 unique messages — exceeding the old global
|
|
// seenMessageContentRef prune threshold (1000→500). Under the old
|
|
// dual-set design the global set would drop msg-0's key during pruning,
|
|
// so a later mesh echo of msg-0 would pass the global check and
|
|
// phantom-increment unread. With the fix, messageCache's per-conversation
|
|
// seenContent is the single source of truth and is never pruned.
|
|
const MESSAGE_COUNT = 1001;
|
|
for (let i = 0; i < MESSAGE_COUNT; i++) {
|
|
const msg: Message = {
|
|
id: i,
|
|
type: 'CHAN',
|
|
conversation_key: convKey,
|
|
text: `msg-${i}`,
|
|
sender_timestamp: 1700000000 + i,
|
|
received_at: 1700000000 + i,
|
|
paths: null,
|
|
txt_type: 0,
|
|
signature: null,
|
|
sender_key: null,
|
|
outgoing: false,
|
|
acked: 0,
|
|
sender_name: null,
|
|
};
|
|
handleMessageEvent(state, msg, 'other_active_conv');
|
|
}
|
|
|
|
const stateKey = getStateKey('channel', convKey);
|
|
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT);
|
|
|
|
// Now a mesh echo of msg-0 arrives (same content, different id).
|
|
const echo: Message = {
|
|
id: 9999,
|
|
type: 'CHAN',
|
|
conversation_key: convKey,
|
|
text: 'msg-0',
|
|
sender_timestamp: 1700000000, // same sender_timestamp as original
|
|
received_at: 1700001000,
|
|
paths: null,
|
|
txt_type: 0,
|
|
signature: null,
|
|
sender_key: null,
|
|
outgoing: false,
|
|
acked: 0,
|
|
sender_name: null,
|
|
};
|
|
const result = handleMessageEvent(state, echo, 'other_active_conv');
|
|
|
|
// Must NOT increment unread — the echo is a duplicate
|
|
expect(result.unreadIncremented).toBe(false);
|
|
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT);
|
|
});
|
|
});
|
|
|
|
describe('Integration: Message Content Key Contract', () => {
|
|
it('generates consistent keys for deduplication', () => {
|
|
const msg = fixtures.channel_message.expected_ws_event.data as unknown as Message;
|
|
msg.id = 1;
|
|
|
|
// Same content with different IDs should generate same key
|
|
const msg2 = { ...msg, id: 2 };
|
|
|
|
expect(getMessageContentKey(msg)).toBe(getMessageContentKey(msg2));
|
|
});
|
|
|
|
it('key format matches backend expectation', () => {
|
|
const msg = fixtures.channel_message.expected_ws_event.data as unknown as Message;
|
|
|
|
const key = getMessageContentKey(msg);
|
|
|
|
// Key should be: type-conversation_key-text-sender_timestamp
|
|
expect(key).toContain(msg.type);
|
|
expect(key).toContain(msg.conversation_key);
|
|
expect(key).toContain(String(msg.sender_timestamp));
|
|
});
|
|
});
|
|
|
|
describe('Integration: State Key Contract', () => {
|
|
it('generates correct channel state key', () => {
|
|
const channelKey = fixtures.channel_message.expected_ws_event.data.conversation_key;
|
|
|
|
const stateKey = getStateKey('channel', channelKey);
|
|
|
|
expect(stateKey).toBe(`channel-${channelKey}`);
|
|
});
|
|
|
|
it('generates correct contact state key with full public key', () => {
|
|
const publicKey = fixtures.advertisement_with_gps.expected_ws_event.data.public_key;
|
|
|
|
const stateKey = getStateKey('contact', publicKey);
|
|
|
|
expect(stateKey).toBe(`contact-${publicKey}`);
|
|
});
|
|
});
|
|
|
|
// --- Contact merge tests (imports real mergeContactIntoList) ---
|
|
|
|
function makeContact(overrides: Partial<Contact> = {}): Contact {
|
|
return {
|
|
public_key: 'abc123',
|
|
name: 'TestNode',
|
|
type: 1,
|
|
flags: 0,
|
|
last_path: null,
|
|
last_path_len: 0,
|
|
last_advert: null,
|
|
lat: null,
|
|
lon: null,
|
|
last_seen: null,
|
|
on_radio: true,
|
|
last_contacted: null,
|
|
last_read_at: null,
|
|
first_seen: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('Integration: Contact Merge', () => {
|
|
it('appends new contact to list', () => {
|
|
const existing = [makeContact({ public_key: 'aaa', name: 'Alpha' })];
|
|
const incoming = makeContact({ public_key: 'bbb', name: 'Beta' });
|
|
|
|
const result = mergeContactIntoList(existing, incoming);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[1].name).toBe('Beta');
|
|
});
|
|
|
|
it('merges existing contact (updates name, preserves other fields)', () => {
|
|
const existing = [makeContact({ public_key: 'aaa', name: 'Alpha', lat: 47.0 })];
|
|
const incoming = makeContact({ public_key: 'aaa', name: 'Alpha-Updated' });
|
|
|
|
const result = mergeContactIntoList(existing, incoming);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe('Alpha-Updated');
|
|
// Spread semantics: incoming lat (null) overwrites existing lat
|
|
expect(result[0].public_key).toBe('aaa');
|
|
});
|
|
|
|
it('returns same array reference when contact is unchanged', () => {
|
|
const contact = makeContact({ public_key: 'aaa', name: 'Alpha' });
|
|
const existing = [contact];
|
|
// Incoming with identical values
|
|
const incoming = makeContact({ public_key: 'aaa', name: 'Alpha' });
|
|
|
|
const result = mergeContactIntoList(existing, incoming);
|
|
|
|
expect(result).toBe(existing); // referential equality
|
|
});
|
|
|
|
it('partial update merges without clobbering unrelated fields', () => {
|
|
const existing = [makeContact({ public_key: 'aaa', name: 'Alpha', lat: 47.0, lon: -122.0 })];
|
|
// Incoming update only changes lat
|
|
const incoming = makeContact({ public_key: 'aaa', name: 'Alpha', lat: 48.0, lon: -122.0 });
|
|
|
|
const result = mergeContactIntoList(existing, incoming);
|
|
|
|
expect(result[0].lat).toBe(48.0);
|
|
expect(result[0].lon).toBe(-122.0);
|
|
expect(result[0].name).toBe('Alpha');
|
|
});
|
|
});
|
|
|
|
// --- ACK + messageCache propagation tests ---
|
|
|
|
describe('Integration: ACK + messageCache propagation', () => {
|
|
beforeEach(() => {
|
|
messageCache.clear();
|
|
});
|
|
|
|
it('updateAck updates acked count on cached message', () => {
|
|
const msg: Message = {
|
|
id: 100,
|
|
type: 'PRIV',
|
|
conversation_key: 'pk_abc',
|
|
text: 'Hello',
|
|
sender_timestamp: 1700000000,
|
|
received_at: 1700000000,
|
|
paths: null,
|
|
txt_type: 0,
|
|
signature: null,
|
|
sender_key: null,
|
|
outgoing: true,
|
|
acked: 0,
|
|
sender_name: null,
|
|
};
|
|
messageCache.addMessage('pk_abc', msg, 'key-100');
|
|
|
|
messageCache.updateAck(100, 1);
|
|
|
|
const entry = messageCache.get('pk_abc');
|
|
expect(entry).toBeDefined();
|
|
expect(entry!.messages[0].acked).toBe(1);
|
|
});
|
|
|
|
it('updateAck updates paths when longer', () => {
|
|
const msg: Message = {
|
|
id: 101,
|
|
type: 'PRIV',
|
|
conversation_key: 'pk_abc',
|
|
text: 'Test',
|
|
sender_timestamp: 1700000001,
|
|
received_at: 1700000001,
|
|
paths: [{ path: 'aa', received_at: 1700000001 }],
|
|
txt_type: 0,
|
|
signature: null,
|
|
sender_key: null,
|
|
outgoing: true,
|
|
acked: 1,
|
|
sender_name: null,
|
|
};
|
|
messageCache.addMessage('pk_abc', msg, 'key-101');
|
|
|
|
const longerPaths = [
|
|
{ path: 'aa', received_at: 1700000001 },
|
|
{ path: 'bb', received_at: 1700000002 },
|
|
];
|
|
messageCache.updateAck(101, 2, longerPaths);
|
|
|
|
const entry = messageCache.get('pk_abc');
|
|
expect(entry!.messages[0].paths).toHaveLength(2);
|
|
expect(entry!.messages[0].acked).toBe(2);
|
|
});
|
|
|
|
it('preserves higher existing ack count (max semantics)', () => {
|
|
const msg: Message = {
|
|
id: 102,
|
|
type: 'PRIV',
|
|
conversation_key: 'pk_abc',
|
|
text: 'Max test',
|
|
sender_timestamp: 1700000002,
|
|
received_at: 1700000002,
|
|
paths: null,
|
|
txt_type: 0,
|
|
signature: null,
|
|
sender_key: null,
|
|
outgoing: true,
|
|
acked: 5,
|
|
sender_name: null,
|
|
};
|
|
messageCache.addMessage('pk_abc', msg, 'key-102');
|
|
|
|
// Try to update with a lower ack count
|
|
messageCache.updateAck(102, 3);
|
|
|
|
const entry = messageCache.get('pk_abc');
|
|
expect(entry!.messages[0].acked).toBe(5); // max(5, 3) = 5
|
|
});
|
|
|
|
it('is a no-op for unknown message ID', () => {
|
|
const msg: Message = {
|
|
id: 103,
|
|
type: 'PRIV',
|
|
conversation_key: 'pk_abc',
|
|
text: 'Existing',
|
|
sender_timestamp: 1700000003,
|
|
received_at: 1700000003,
|
|
paths: null,
|
|
txt_type: 0,
|
|
signature: null,
|
|
sender_key: null,
|
|
outgoing: true,
|
|
acked: 0,
|
|
sender_name: null,
|
|
};
|
|
messageCache.addMessage('pk_abc', msg, 'key-103');
|
|
|
|
// Update a non-existent message ID — should not throw or modify anything
|
|
messageCache.updateAck(999, 1);
|
|
|
|
const entry = messageCache.get('pk_abc');
|
|
expect(entry!.messages[0].acked).toBe(0); // unchanged
|
|
});
|
|
});
|