Files
Remote-Terminal-for-MeshCore/frontend/src/test/messageCache.test.ts
2026-03-16 17:30:15 -07:00

441 lines
15 KiB
TypeScript

/**
* Tests for the LRU message cache.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
ConversationMessageCache,
MAX_CACHED_CONVERSATIONS,
MAX_MESSAGES_PER_ENTRY,
reconcileConversationMessages,
} from '../hooks/useConversationMessages';
import type { Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'channel123',
text: 'Hello world',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: null,
...overrides,
};
}
function createEntry(messages: Message[] = [], hasOlderMessages = false) {
return { messages, hasOlderMessages };
}
describe('messageCache', () => {
let messageCache: ConversationMessageCache;
beforeEach(() => {
messageCache = new ConversationMessageCache();
});
describe('get/set', () => {
it('returns undefined for missing entries', () => {
expect(messageCache.get('nonexistent')).toBeUndefined();
});
it('stores and retrieves entries', () => {
const msg = createMessage();
const entry = createEntry([msg], true);
messageCache.set('conv1', entry);
const result = messageCache.get('conv1');
expect(result).toBeDefined();
expect(result!.messages).toHaveLength(1);
expect(result!.messages[0].text).toBe('Hello world');
expect(result!.hasOlderMessages).toBe(true);
});
it('trims messages to MAX_MESSAGES_PER_ENTRY on set', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY + 50 }, (_, i) =>
createMessage({ id: i, received_at: 1700000000 + i })
);
messageCache.set('conv1', createEntry(messages));
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(MAX_MESSAGES_PER_ENTRY);
});
it('keeps the most recent messages when trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY + 10 }, (_, i) =>
createMessage({ id: i, received_at: 1700000000 + i })
);
messageCache.set('conv1', createEntry(messages));
const entry = messageCache.get('conv1');
// Most recent message (highest received_at) should be present
const maxReceivedAt = MAX_MESSAGES_PER_ENTRY + 10 - 1;
expect(entry!.messages.some((m) => m.received_at === 1700000000 + maxReceivedAt)).toBe(true);
// Oldest messages should be trimmed
expect(entry!.messages.some((m) => m.received_at === 1700000000)).toBe(false);
});
it('sets hasOlderMessages to true when trimming', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY + 1 }, (_, i) =>
createMessage({ id: i, received_at: 1700000000 + i })
);
messageCache.set('conv1', createEntry(messages, false));
const entry = messageCache.get('conv1');
expect(entry!.hasOlderMessages).toBe(true);
});
it('overwrites existing entries', () => {
const entry1 = createEntry([createMessage({ text: 'first' })]);
const entry2 = createEntry([createMessage({ text: 'second' })]);
messageCache.set('conv1', entry1);
messageCache.set('conv1', entry2);
const result = messageCache.get('conv1');
expect(result!.messages[0].text).toBe('second');
});
});
describe('LRU eviction', () => {
it('evicts least-recently-used entry when over capacity', () => {
// Fill cache to capacity + 1
for (let i = 0; i <= MAX_CACHED_CONVERSATIONS; i++) {
messageCache.set(`conv${i}`, createEntry([createMessage({ id: i })]));
}
// conv0 (LRU) should be evicted
expect(messageCache.get('conv0')).toBeUndefined();
// Remaining entries should still exist
for (let i = 1; i <= MAX_CACHED_CONVERSATIONS; i++) {
expect(messageCache.get(`conv${i}`)).toBeDefined();
}
});
it('promotes accessed entries to MRU', () => {
// Fill cache to capacity
for (let i = 0; i < MAX_CACHED_CONVERSATIONS; i++) {
messageCache.set(`conv${i}`, createEntry([createMessage({ id: i })]));
}
// Access conv0, promoting it to MRU
messageCache.get('conv0');
// Add one more - conv1 should now be LRU and get evicted
messageCache.set('conv_new', createEntry());
expect(messageCache.get('conv0')).toBeDefined(); // Was promoted
expect(messageCache.get('conv1')).toBeUndefined(); // Was LRU, evicted
expect(messageCache.get('conv_new')).toBeDefined();
});
it('promotes set entries to MRU', () => {
for (let i = 0; i < MAX_CACHED_CONVERSATIONS; i++) {
messageCache.set(`conv${i}`, createEntry([createMessage({ id: i })]));
}
// Re-set conv0 (promotes to MRU)
messageCache.set('conv0', createEntry([createMessage({ id: 100 })]));
// Add one more - conv1 should be LRU and get evicted
messageCache.set('conv_new', createEntry());
expect(messageCache.get('conv0')).toBeDefined();
expect(messageCache.get('conv1')).toBeUndefined();
});
});
describe('addMessage', () => {
it('adds message to existing cached conversation and returns true', () => {
messageCache.set('conv1', createEntry([]));
const msg = createMessage({ id: 10, text: 'New message' });
const result = messageCache.addMessage('conv1', msg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
expect(entry!.messages[0].text).toBe('New message');
});
it('deduplicates by content key and returns false', () => {
messageCache.set('conv1', createEntry([]));
const msg1 = createMessage({ id: 10, text: 'Hello' });
expect(messageCache.addMessage('conv1', msg1)).toBe(true);
// Same content key, different message id
const msg2 = createMessage({ id: 11, text: 'Hello' });
expect(messageCache.addMessage('conv1', msg2)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
});
it('deduplicates by message id and returns false', () => {
messageCache.set('conv1', createEntry([createMessage({ id: 10, text: 'Original' })]));
// Same id, different content key
const msg = createMessage({ id: 10, text: 'Different' });
expect(messageCache.addMessage('conv1', msg)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
expect(entry!.messages[0].text).toBe('Original');
});
it('trims to MAX_MESSAGES_PER_ENTRY when adding to a full entry', () => {
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY }, (_, i) =>
createMessage({ id: i, received_at: 1700000000 + i })
);
messageCache.set('conv1', createEntry(messages));
// Add one more (newest)
const newMsg = createMessage({
id: MAX_MESSAGES_PER_ENTRY,
text: 'newest',
received_at: 1700000000 + MAX_MESSAGES_PER_ENTRY,
});
const result = messageCache.addMessage('conv1', newMsg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(MAX_MESSAGES_PER_ENTRY);
// Newest message should be kept
expect(entry!.messages.some((m) => m.id === MAX_MESSAGES_PER_ENTRY)).toBe(true);
// Oldest message (id=0) should be trimmed
expect(entry!.messages.some((m) => m.id === 0)).toBe(false);
});
it('auto-creates a minimal entry for never-visited conversations and returns true', () => {
const msg = createMessage({ id: 10, text: 'First contact' });
const result = messageCache.addMessage('new_conv', msg);
expect(result).toBe(true);
const entry = messageCache.get('new_conv');
expect(entry).toBeDefined();
expect(entry!.messages).toHaveLength(1);
expect(entry!.messages[0].text).toBe('First contact');
expect(entry!.hasOlderMessages).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);
// 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' });
expect(messageCache.addMessage('new_conv', msg)).toBe(true);
// Duplicate via mesh echo
expect(messageCache.addMessage('new_conv', msg)).toBe(false);
const entry = messageCache.get('new_conv');
expect(entry!.messages).toHaveLength(1);
});
});
describe('updateAck', () => {
it('updates ack count for a message in cache', () => {
const msg = createMessage({ id: 42, acked: 0 });
messageCache.set('conv1', createEntry([msg]));
messageCache.updateAck(42, 3);
const entry = messageCache.get('conv1');
expect(entry!.messages[0].acked).toBe(3);
});
it('updates paths when provided', () => {
const msg = createMessage({ id: 42, acked: 0, paths: null });
messageCache.set('conv1', createEntry([msg]));
const newPaths = [{ path: '1A2B', received_at: 1700000000 }];
messageCache.updateAck(42, 1, newPaths);
const entry = messageCache.get('conv1');
expect(entry!.messages[0].acked).toBe(1);
expect(entry!.messages[0].paths).toEqual(newPaths);
});
it('does not modify paths when not provided', () => {
const existingPaths = [{ path: '1A2B', received_at: 1700000000 }];
const msg = createMessage({ id: 42, acked: 1, paths: existingPaths });
messageCache.set('conv1', createEntry([msg]));
messageCache.updateAck(42, 2);
const entry = messageCache.get('conv1');
expect(entry!.messages[0].acked).toBe(2);
expect(entry!.messages[0].paths).toEqual(existingPaths);
});
it('scans across multiple cached conversations', () => {
const msg1 = createMessage({ id: 10, conversation_key: 'conv1', acked: 0 });
const msg2 = createMessage({ id: 20, conversation_key: 'conv2', acked: 0 });
messageCache.set('conv1', createEntry([msg1]));
messageCache.set('conv2', createEntry([msg2]));
messageCache.updateAck(20, 5);
expect(messageCache.get('conv1')!.messages[0].acked).toBe(0); // Unchanged
expect(messageCache.get('conv2')!.messages[0].acked).toBe(5); // Updated
});
it('does nothing for unknown message id', () => {
const msg = createMessage({ id: 42, acked: 0 });
messageCache.set('conv1', createEntry([msg]));
messageCache.updateAck(999, 3);
expect(messageCache.get('conv1')!.messages[0].acked).toBe(0);
});
});
describe('remove', () => {
it('removes a specific conversation', () => {
messageCache.set('conv1', createEntry());
messageCache.set('conv2', createEntry());
messageCache.remove('conv1');
expect(messageCache.get('conv1')).toBeUndefined();
expect(messageCache.get('conv2')).toBeDefined();
});
it('does nothing for non-existent key', () => {
messageCache.set('conv1', createEntry());
messageCache.remove('nonexistent');
expect(messageCache.get('conv1')).toBeDefined();
});
});
describe('reconcile', () => {
it('returns null when cache matches fetched data (happy path)', () => {
const msgs = [
createMessage({ id: 1, acked: 2 }),
createMessage({ id: 2, acked: 0 }),
createMessage({ id: 3, acked: 1 }),
];
const fetched = [
createMessage({ id: 1, acked: 2 }),
createMessage({ id: 2, acked: 0 }),
createMessage({ id: 3, acked: 1 }),
];
expect(reconcileConversationMessages(msgs, fetched)).toBeNull();
});
it('detects new messages missing from cache', () => {
const current = [createMessage({ id: 1 }), createMessage({ id: 2 })];
const fetched = [
createMessage({ id: 1 }),
createMessage({ id: 2 }),
createMessage({ id: 3, text: 'missed via WS' }),
];
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged!.map((m) => m.id)).toEqual([1, 2, 3]);
});
it('detects stale ack counts', () => {
const current = [createMessage({ id: 1, acked: 0 })];
const fetched = [createMessage({ id: 1, acked: 3 })];
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].acked).toBe(3);
});
it('preserves older paginated messages not in fetch', () => {
// Current state has recent page + older paginated messages
const current = [
createMessage({ id: 3 }),
createMessage({ id: 2 }),
createMessage({ id: 1 }), // older, from pagination
];
// Fetch only returns recent page with a new message
const fetched = [
createMessage({ id: 4, text: 'new' }),
createMessage({ id: 3 }),
createMessage({ id: 2 }),
];
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
// Should have fetched page + older paginated message
expect(merged!.map((m) => m.id)).toEqual([4, 3, 2, 1]);
});
it('returns null for empty fetched and empty current', () => {
expect(reconcileConversationMessages([], [])).toBeNull();
});
it('detects difference when current is empty but fetch has messages', () => {
const fetched = [createMessage({ id: 1 })];
const merged = reconcileConversationMessages([], fetched);
expect(merged).not.toBeNull();
expect(merged!).toHaveLength(1);
});
it('detects stale paths', () => {
const current = [
createMessage({ id: 1, acked: 1, paths: [{ path: '1A', received_at: 1700000000 }] }),
];
const fetched = [
createMessage({
id: 1,
acked: 1,
paths: [
{ path: '1A', received_at: 1700000000 },
{ path: '2B', received_at: 1700000001 },
],
}),
];
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].paths).toHaveLength(2);
});
it('detects stale text (e.g. post-decryption)', () => {
const current = [createMessage({ id: 1, text: '[encrypted]' })];
const fetched = [createMessage({ id: 1, text: 'Hello world' })];
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].text).toBe('Hello world');
});
it('returns null when acked, paths length, and text all match', () => {
const paths = [{ path: '1A', received_at: 1700000000 }];
const current = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
const fetched = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
expect(reconcileConversationMessages(current, fetched)).toBeNull();
});
});
});