Files
Remote-Terminal-for-MeshCore/frontend/src/test/useRealtimeAppState.test.ts
2026-03-09 18:27:01 -07:00

217 lines
5.8 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useRealtimeAppState } from '../hooks/useRealtimeAppState';
import type { Channel, Contact, Conversation, HealthStatus, Message, RawPacket } from '../types';
const mocks = vi.hoisted(() => ({
api: {
getChannels: vi.fn(),
},
toast: {
success: vi.fn(),
error: vi.fn(),
},
messageCache: {
addMessage: vi.fn(),
remove: vi.fn(),
updateAck: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
vi.mock('../messageCache', () => mocks.messageCache);
const publicChannel: Channel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
const incomingDm: Message = {
id: 7,
type: 'PRIV',
conversation_key: 'aa'.repeat(32),
text: 'hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: 'aa'.repeat(32),
outgoing: false,
acked: 0,
sender_name: 'Alice',
};
function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppState>[0]> = {}) {
const setHealth = vi.fn();
const setRawPackets = vi.fn();
const setChannels = vi.fn();
const setContacts = vi.fn();
return {
args: {
prevHealthRef: { current: null as HealthStatus | null },
setHealth,
fetchConfig: vi.fn(),
setRawPackets,
triggerReconcile: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
setChannels,
fetchAllContacts: vi.fn(async () => [] as Contact[]),
setContacts,
blockedKeysRef: { current: [] as string[] },
blockedNamesRef: { current: [] as string[] },
activeConversationRef: { current: null as Conversation | null },
hasNewerMessagesRef: { current: false },
addMessageIfNew: vi.fn(),
trackNewMessage: vi.fn(),
incrementUnread: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
updateMessageAck: vi.fn(),
...overrides,
},
fns: {
setHealth,
setRawPackets,
setChannels,
setContacts,
},
};
}
describe('useRealtimeAppState', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.api.getChannels.mockResolvedValue([publicChannel]);
});
it('reconnect clears raw packets and refetches channels/contacts/unreads', async () => {
const contacts: Contact[] = [
{
public_key: 'bb'.repeat(32),
name: 'Bob',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
const { args, fns } = createRealtimeArgs({
fetchAllContacts: vi.fn(async () => contacts),
});
const { result } = renderHook(() => useRealtimeAppState(args));
act(() => {
result.current.onReconnect?.();
});
await waitFor(() => {
expect(args.triggerReconcile).toHaveBeenCalledTimes(1);
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
expect(fns.setRawPackets).toHaveBeenCalledWith([]);
expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]);
expect(fns.setContacts).toHaveBeenCalledWith(contacts);
});
});
it('tracks unread state for a new non-active incoming message', () => {
mocks.messageCache.addMessage.mockReturnValue(true);
const { args } = createRealtimeArgs({
checkMention: vi.fn(() => true),
});
const { result } = renderHook(() => useRealtimeAppState(args));
act(() => {
result.current.onMessage?.(incomingDm);
});
expect(args.addMessageIfNew).not.toHaveBeenCalled();
expect(args.trackNewMessage).toHaveBeenCalledWith(incomingDm);
expect(mocks.messageCache.addMessage).toHaveBeenCalledWith(
incomingDm.conversation_key,
incomingDm,
expect.any(String)
);
expect(args.incrementUnread).toHaveBeenCalledWith(
`contact-${incomingDm.conversation_key}`,
true
);
});
it('deleting the active contact clears it and marks fallback recovery pending', () => {
const pendingDeleteFallbackRef = { current: false };
const activeConversationRef = {
current: {
type: 'contact',
id: incomingDm.conversation_key,
name: 'Alice',
} satisfies Conversation,
};
const { args, fns } = createRealtimeArgs({
activeConversationRef,
pendingDeleteFallbackRef,
});
const { result } = renderHook(() => useRealtimeAppState(args));
act(() => {
result.current.onContactDeleted?.(incomingDm.conversation_key);
});
expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function));
expect(mocks.messageCache.remove).toHaveBeenCalledWith(incomingDm.conversation_key);
expect(args.setActiveConversation).toHaveBeenCalledWith(null);
expect(pendingDeleteFallbackRef.current).toBe(true);
});
it('appends raw packets using observation identity dedup', () => {
const { args, fns } = createRealtimeArgs();
const packet: RawPacket = {
id: 1,
observation_id: 2,
timestamp: 1700000000,
data: 'aabb',
payload_type: 'GROUP_TEXT',
snr: 7.5,
rssi: -80,
decrypted: false,
decrypted_info: null,
};
const { result } = renderHook(() => useRealtimeAppState(args));
act(() => {
result.current.onRawPacket?.(packet);
});
expect(fns.setRawPackets).toHaveBeenCalledWith(expect.any(Function));
});
});