Add E2E-ish tests

This commit is contained in:
Jack Kingsman
2026-01-13 19:48:19 -08:00
parent f368b80221
commit 32ed00fd34
6 changed files with 1045 additions and 62 deletions
+63
View File
@@ -0,0 +1,63 @@
{
"channel_message": {
"description": "Channel message on #six77 from Flightless🥝",
"raw_packet_hex": "1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818",
"channel_name": "#six77",
"channel_key_hex": "7aba109edcf304a84433cb71d0f3ab73",
"expected_ws_event": {
"type": "message",
"data": {
"type": "CHAN",
"conversation_key": "7ABA109EDCF304A84433CB71D0F3AB73",
"text": "Flightless🥝: hello there; this hashtag room is essentially public. MeshCore has great crypto; use private rooms or DMs for private comms instead!",
"sender_timestamp": 1766604717,
"outgoing": false,
"acked": 0
}
}
},
"advertisement_with_gps": {
"description": "Repeater advertisement with GPS coordinates from Can O Mesh 2",
"raw_packet_hex": "1106538B1CD273868576DC7F679B493F9AB5AC316173E1A56D3388BC3BA75F583F63AB0D1BA2A8ABD0BC6669DBF719E67E4C8517BA4E0D6F8C96A323E9D13A77F2630DED965A5C17C3EC6ED1601EEFE857749DA24E9F39CBEACD722C3708F433DB5FA9BAF0BAF9BC5B1241069290FEEB029A839EF843616E204F204D657368203220F09FA5AB",
"expected_ws_event": {
"type": "contact",
"data": {
"public_key": "8576dc7f679b493f9ab5ac316173e1a56d3388bc3ba75f583f63ab0d1ba2a8ab",
"name": "Can O Mesh 2 🥫",
"type": 2,
"lat": 49.02056,
"lon": -123.82935
}
}
},
"advertisement_chat_node": {
"description": "Chat node advertisement with GPS from Flightless🥝",
"raw_packet_hex": "1100AE92564C5C9884854F04F469BBB2BAB8871A078053AF6CF4AA2C014B18CE8A832DBF6669128E9476F36320F21D1B37FF1CF31680F50F4B17EDABCC7CF8C47D3C5E1DF3AFD0C8721EA06A8078462EF241DEF80AD6922751F206E3BB121DFB604F4146D60D913628D902602DB5F8466C696768746C657373F09FA59D",
"expected_ws_event": {
"type": "contact",
"data": {
"public_key": "ae92564c5c9884854f04f469bbb2bab8871a078053af6cf4aa2c014b18ce8a83",
"name": "Flightless🥝",
"type": 1,
"lat": 47.786038,
"lon": -122.344096
}
}
},
"message_acked": {
"description": "ACK event when a message is confirmed delivered",
"expected_ws_event": {
"type": "message_acked",
"data": {
"message_id": 42,
"ack_count": 1
}
}
},
"duplicate_channel_message": {
"description": "Same channel message arriving twice (via different paths) should only produce one message event",
"raw_packet_hex": "1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818",
"channel_name": "#six77",
"expected_behavior": "First packet creates message and broadcasts, second packet is deduplicated (no broadcast)"
}
}
+369
View File
@@ -0,0 +1,369 @@
/**
* Integration tests for WebSocket event handling.
*
* These tests verify that WebSocket events (as produced by the backend)
* are correctly processed by the frontend state handlers.
*
* 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 } from 'vitest';
import fixtures from './fixtures/websocket_events.json';
import { getMessageContentKey } from '../hooks/useConversationMessages';
import { getStateKey } from '../utils/conversationState';
import type { Message, Contact, Channel } from '../types';
/**
* Simulate the WebSocket message handler from App.tsx.
* This is the core logic we're testing.
*/
interface MockState {
messages: Message[];
contacts: Contact[];
channels: Channel[];
unreadCounts: Record<string, number>;
lastMessageTimes: Record<string, number>;
seenMessageContent: Set<string>;
}
function createMockState(): MockState {
return {
messages: [],
contacts: [],
channels: [],
unreadCounts: {},
lastMessageTimes: {},
seenMessageContent: new Set(),
};
}
/**
* Simulate handling a message WebSocket event.
* Mirrors the logic in App.tsx onMessage handler.
*/
function handleMessageEvent(
state: MockState,
msg: Message,
activeConversationKey: string | null
): { added: boolean; unreadIncremented: boolean } {
const contentKey = getMessageContentKey(msg);
let added = false;
let unreadIncremented = false;
// Check if message is for active conversation
const isForActiveConversation = activeConversationKey !== null &&
msg.conversation_key === activeConversationKey;
// Add to messages if for active conversation (with deduplication)
if (isForActiveConversation) {
if (!state.seenMessageContent.has(contentKey)) {
state.seenMessageContent.add(contentKey);
state.messages.push(msg);
added = true;
}
}
// Update last message time
const stateKey = msg.type === 'CHAN'
? getStateKey('channel', msg.conversation_key)
: getStateKey('contact', msg.conversation_key);
state.lastMessageTimes[stateKey] = msg.received_at;
// Increment unread if not for active conversation and not outgoing
if (!msg.outgoing && !isForActiveConversation) {
// Deduplicate by content
if (!state.seenMessageContent.has(contentKey)) {
state.seenMessageContent.add(contentKey);
state.unreadCounts[stateKey] = (state.unreadCounts[stateKey] || 0) + 1;
unreadIncremented = true;
}
}
return { added, unreadIncremented };
}
/**
* Simulate handling a contact WebSocket event.
*/
function handleContactEvent(state: MockState, contact: Contact): void {
const idx = state.contacts.findIndex(c => c.public_key === contact.public_key);
if (idx >= 0) {
// Update existing contact
state.contacts[idx] = { ...state.contacts[idx], ...contact };
} else {
// Add new contact
state.contacts.push(contact);
}
}
/**
* Simulate handling a message_acked WebSocket event.
*/
function handleMessageAckedEvent(
state: MockState,
messageId: number,
ackCount: number
): boolean {
const idx = state.messages.findIndex(m => m.id === messageId);
if (idx >= 0) {
state.messages[idx] = { ...state.messages[idx], acked: ackCount };
return true;
}
return false;
}
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', () => {
// Note: duplicate_channel_message fixture references the same packet data as channel_message
it('deduplicates messages by content when adding to list', () => {
const state = createMockState();
// Use channel_message fixture data since duplicate_channel_message references same packet
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;
// Both arrive for active conversation
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;
// Both arrive for non-active conversation
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: Contact/Advertisement Events', () => {
const fixture = fixtures.advertisement_with_gps;
it('creates new contact from advertisement', () => {
const state = createMockState();
const contact = fixture.expected_ws_event.data as unknown as Contact;
handleContactEvent(state, contact);
expect(state.contacts).toHaveLength(1);
expect(state.contacts[0].public_key).toBe(contact.public_key);
expect(state.contacts[0].name).toBe('Can O Mesh 2 🥫');
expect(state.contacts[0].type).toBe(2); // Repeater
expect(state.contacts[0].lat).toBeCloseTo(49.02056, 4);
expect(state.contacts[0].lon).toBeCloseTo(-123.82935, 4);
});
it('updates existing contact from advertisement', () => {
const state = createMockState();
// Add existing contact
state.contacts.push({
public_key: fixture.expected_ws_event.data.public_key,
name: 'Old Name',
type: 0,
on_radio: false,
last_read_at: null,
} as Contact);
// Process new advertisement
const contact = fixture.expected_ws_event.data as unknown as Contact;
handleContactEvent(state, contact);
expect(state.contacts).toHaveLength(1);
expect(state.contacts[0].name).toBe('Can O Mesh 2 🥫'); // Updated
expect(state.contacts[0].type).toBe(2); // Updated
});
it('preserves contact GPS from chat node advertisement', () => {
const state = createMockState();
const chatFixture = fixtures.advertisement_chat_node;
const contact = chatFixture.expected_ws_event.data as unknown as Contact;
handleContactEvent(state, contact);
expect(state.contacts[0].lat).toBeCloseTo(47.786038, 4);
expect(state.contacts[0].lon).toBeCloseTo(-122.344096, 4);
expect(state.contacts[0].type).toBe(1); // Chat node
});
});
describe('Integration: ACK Events', () => {
const fixture = fixtures.message_acked;
it('updates message ack count', () => {
const state = createMockState();
// Add a message that's waiting for ACK
state.messages.push({
id: 42,
type: 'PRIV',
conversation_key: 'abc123',
text: 'Hello',
sender_timestamp: 1700000000,
received_at: 1700000000,
path_len: null,
txt_type: 0,
signature: null,
outgoing: true,
acked: 0,
});
const ackData = fixture.expected_ws_event.data;
const updated = handleMessageAckedEvent(state, ackData.message_id, ackData.ack_count);
expect(updated).toBe(true);
expect(state.messages[0].acked).toBe(1);
});
it('returns false for unknown message id', () => {
const state = createMockState();
const ackData = fixture.expected_ws_event.data;
const updated = handleMessageAckedEvent(state, ackData.message_id, ackData.ack_count);
expect(updated).toBe(false);
});
it('updates to multiple ack count for flood echoes', () => {
const state = createMockState();
state.messages.push({
id: 42,
type: 'CHAN',
conversation_key: 'channel123',
text: 'Hello',
sender_timestamp: 1700000000,
received_at: 1700000000,
path_len: null,
txt_type: 0,
signature: null,
outgoing: true,
acked: 0,
});
// Multiple flood echoes
handleMessageAckedEvent(state, 42, 1);
handleMessageAckedEvent(state, 42, 2);
handleMessageAckedEvent(state, 42, 3);
expect(state.messages[0].acked).toBe(3);
});
});
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 prefix', () => {
const publicKey = fixtures.advertisement_with_gps.expected_ws_event.data.public_key;
const stateKey = getStateKey('contact', publicKey);
// Contact state key uses 12-char prefix
expect(stateKey).toBe(`contact-${publicKey.substring(0, 12)}`);
});
});