mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
/**
|
|
* Tests for the LRU message cache.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import * as messageCache from '../messageCache';
|
|
import { MAX_CACHED_CONVERSATIONS, MAX_MESSAGES_PER_ENTRY } from '../messageCache';
|
|
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,
|
|
outgoing: false,
|
|
acked: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createEntry(messages: Message[] = [], hasOlderMessages = false): messageCache.CacheEntry {
|
|
const seenContent = new Set<string>();
|
|
for (const msg of messages) {
|
|
seenContent.add(`${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`);
|
|
}
|
|
return { messages, seenContent, hasOlderMessages };
|
|
}
|
|
|
|
describe('messageCache', () => {
|
|
beforeEach(() => {
|
|
messageCache.clear();
|
|
});
|
|
|
|
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');
|
|
expect(messageCache.size()).toBe(1);
|
|
});
|
|
});
|
|
|
|
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', () => {
|
|
messageCache.set('conv1', createEntry([]));
|
|
|
|
const msg = createMessage({ id: 10, text: 'New message' });
|
|
messageCache.addMessage('conv1', msg, 'CHAN-channel123-New message-1700000000');
|
|
|
|
const entry = messageCache.get('conv1');
|
|
expect(entry!.messages).toHaveLength(1);
|
|
expect(entry!.messages[0].text).toBe('New message');
|
|
});
|
|
|
|
it('deduplicates by content key', () => {
|
|
messageCache.set('conv1', createEntry([]));
|
|
|
|
const msg1 = createMessage({ id: 10, text: 'Hello' });
|
|
const contentKey = 'CHAN-channel123-Hello-1700000000';
|
|
messageCache.addMessage('conv1', msg1, contentKey);
|
|
|
|
// Same content key, different message id
|
|
const msg2 = createMessage({ id: 11, text: 'Hello' });
|
|
messageCache.addMessage('conv1', msg2, contentKey);
|
|
|
|
const entry = messageCache.get('conv1');
|
|
expect(entry!.messages).toHaveLength(1);
|
|
});
|
|
|
|
it('deduplicates by message id', () => {
|
|
messageCache.set('conv1', createEntry([createMessage({ id: 10, text: 'Original' })]));
|
|
|
|
// Same id, different content key
|
|
const msg = createMessage({ id: 10, text: 'Different' });
|
|
messageCache.addMessage('conv1', msg, 'CHAN-channel123-Different-1700000000');
|
|
|
|
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,
|
|
});
|
|
messageCache.addMessage('conv1', newMsg, `CHAN-channel123-newest-${newMsg.sender_timestamp}`);
|
|
|
|
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('ignores messages for non-cached conversations', () => {
|
|
const msg = createMessage({ id: 10 });
|
|
// Should not throw
|
|
messageCache.addMessage('nonexistent', msg, 'key');
|
|
expect(messageCache.size()).toBe(0);
|
|
});
|
|
});
|
|
|
|
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();
|
|
expect(messageCache.size()).toBe(1);
|
|
});
|
|
|
|
it('does nothing for non-existent key', () => {
|
|
messageCache.set('conv1', createEntry());
|
|
messageCache.remove('nonexistent');
|
|
expect(messageCache.size()).toBe(1);
|
|
});
|
|
});
|
|
|
|
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(messageCache.reconcile(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 = messageCache.reconcile(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 = messageCache.reconcile(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 = messageCache.reconcile(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(messageCache.reconcile([], [])).toBeNull();
|
|
});
|
|
|
|
it('detects difference when current is empty but fetch has messages', () => {
|
|
const fetched = [createMessage({ id: 1 })];
|
|
|
|
const merged = messageCache.reconcile([], fetched);
|
|
expect(merged).not.toBeNull();
|
|
expect(merged!).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('clear', () => {
|
|
it('removes all entries', () => {
|
|
messageCache.set('conv1', createEntry());
|
|
messageCache.set('conv2', createEntry());
|
|
messageCache.set('conv3', createEntry());
|
|
|
|
messageCache.clear();
|
|
|
|
expect(messageCache.size()).toBe(0);
|
|
expect(messageCache.get('conv1')).toBeUndefined();
|
|
});
|
|
});
|
|
});
|