mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 01:42:11 +02:00
Add global message search and more e2e tests
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
getRadioConfig: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
migratePreferences: vi.fn(),
|
||||
},
|
||||
useConversationMessagesCalls: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: mocks.api,
|
||||
}));
|
||||
|
||||
vi.mock('../useWebSocket', () => ({
|
||||
useWebSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../hooks')>();
|
||||
return {
|
||||
...actual,
|
||||
useConversationMessages: (activeConversation: unknown, targetMessageId: number | null) => {
|
||||
mocks.useConversationMessagesCalls(activeConversation, targetMessageId);
|
||||
return {
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
hasNewerMessagesRef: { current: false },
|
||||
setMessages: vi.fn(),
|
||||
fetchOlderMessages: vi.fn(async () => {}),
|
||||
fetchNewerMessages: vi.fn(async () => {}),
|
||||
jumpToBottom: vi.fn(),
|
||||
addMessageIfNew: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
triggerReconcile: vi.fn(),
|
||||
};
|
||||
},
|
||||
useUnreadCounts: () => ({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
incrementUnread: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
refreshUnreads: vi.fn(),
|
||||
}),
|
||||
getMessageContentKey: () => 'content-key',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../messageCache', () => ({
|
||||
addMessage: vi.fn(),
|
||||
updateAck: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/StatusBar', () => ({
|
||||
StatusBar: () => <div data-testid="status-bar" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Sidebar', () => ({
|
||||
Sidebar: ({
|
||||
onSelectConversation,
|
||||
activeConversation,
|
||||
}: {
|
||||
onSelectConversation: (conv: { type: 'search' | 'channel'; id: string; name: string }) => void;
|
||||
activeConversation: { type: string; id: string } | null;
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelectConversation({
|
||||
type: 'search',
|
||||
id: 'search',
|
||||
name: 'Message Search',
|
||||
})
|
||||
}
|
||||
>
|
||||
Open Search
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelectConversation({
|
||||
type: 'channel',
|
||||
id: PUBLIC_CHANNEL_KEY,
|
||||
name: 'Public',
|
||||
})
|
||||
}
|
||||
>
|
||||
Open Public
|
||||
</button>
|
||||
<div data-testid="active-conversation">
|
||||
{activeConversation ? `${activeConversation.type}:${activeConversation.id}` : 'none'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChatHeader', () => ({
|
||||
ChatHeader: () => <div data-testid="chat-header" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageList', () => ({
|
||||
MessageList: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageInput', () => ({
|
||||
MessageInput: React.forwardRef((_props, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
|
||||
return <div data-testid="message-input" />;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/NewMessageModal', () => ({
|
||||
NewMessageModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/SearchView', () => ({
|
||||
SearchView: ({
|
||||
onNavigateToMessage,
|
||||
}: {
|
||||
onNavigateToMessage: (target: {
|
||||
id: number;
|
||||
type: 'CHAN' | 'PRIV';
|
||||
conversation_key: string;
|
||||
conversation_name: string;
|
||||
}) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onNavigateToMessage({
|
||||
id: 321,
|
||||
type: 'CHAN',
|
||||
conversation_key: PUBLIC_CHANNEL_KEY,
|
||||
conversation_name: 'Public',
|
||||
})
|
||||
}
|
||||
>
|
||||
Jump Result
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsModal', () => ({
|
||||
SettingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/RawPacketList', () => ({
|
||||
RawPacketList: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ContactInfoPane', () => ({
|
||||
ContactInfoPane: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChannelInfoPane', () => ({
|
||||
ChannelInfoPane: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sheet', () => ({
|
||||
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
Toaster: () => null,
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { App } from '../App';
|
||||
|
||||
describe('App search jump target handling', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mocks.api.getRadioConfig.mockResolvedValue({
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'TestNode',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
sidebar_sort_order: 'recent',
|
||||
last_message_times: {},
|
||||
preferences_migrated: true,
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
bots: [],
|
||||
});
|
||||
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
|
||||
mocks.api.getChannels.mockResolvedValue([
|
||||
{
|
||||
key: PUBLIC_CHANNEL_KEY,
|
||||
name: 'Public',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
},
|
||||
]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('clears jump target when user selects a non-search conversation', async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Open Search').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByText('Open Search')[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Jump Result')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Jump Result'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.useConversationMessagesCalls.mock.calls.some((call) => call[1] === 321)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByText('Open Public')[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastCall =
|
||||
mocks.useConversationMessagesCalls.mock.calls[
|
||||
mocks.useConversationMessagesCalls.mock.calls.length - 1
|
||||
];
|
||||
expect(lastCall?.[1]).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -195,6 +195,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
};
|
||||
handleMessageEvent(state, msg, 'other_active_conv');
|
||||
}
|
||||
@@ -215,6 +216,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
};
|
||||
const result = handleMessageEvent(state, echo, 'other_active_conv');
|
||||
|
||||
@@ -354,6 +356,7 @@ describe('Integration: ACK + messageCache propagation', () => {
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-100');
|
||||
|
||||
@@ -377,6 +380,7 @@ describe('Integration: ACK + messageCache propagation', () => {
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 1,
|
||||
sender_name: null,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-101');
|
||||
|
||||
@@ -404,6 +408,7 @@ describe('Integration: ACK + messageCache propagation', () => {
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 5,
|
||||
sender_name: null,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-102');
|
||||
|
||||
@@ -427,6 +432,7 @@ describe('Integration: ACK + messageCache propagation', () => {
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
};
|
||||
messageCache.addMessage('pk_abc', msg, 'key-103');
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { fireEvent, render, screen, act } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getMessages: (...args: unknown[]) => mockGetMessages(...args),
|
||||
},
|
||||
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
|
||||
}));
|
||||
|
||||
import { SearchView } from '../components/SearchView';
|
||||
|
||||
function createSearchResult(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'ABC123',
|
||||
text: 'hello world',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000000,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: 'Alice',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
contacts: [],
|
||||
channels: [
|
||||
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
|
||||
],
|
||||
onNavigateToMessage: vi.fn(),
|
||||
};
|
||||
|
||||
/** Type the query into the search input and wait for debounced results to render. */
|
||||
async function typeAndWaitForResults(query: string) {
|
||||
const input = screen.getByLabelText('Search messages');
|
||||
// Use fake timers only for the debounce, then switch to real timers for
|
||||
// React's async state updates and waitFor polling.
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: query } });
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
// Wait for the mock API promise to resolve and React to commit
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe('SearchView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders empty state with prompt text', () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses input on mount', () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
expect(screen.getByLabelText('Search messages')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('debounces search input', async () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
vi.useFakeTimers();
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
const input = screen.getByLabelText('Search messages');
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
});
|
||||
|
||||
// Should not have called API yet (within debounce window)
|
||||
expect(mockGetMessages).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past debounce timer
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(mockGetMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: 'hello' }),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
});
|
||||
|
||||
it('displays search results', async () => {
|
||||
mockGetMessages.mockResolvedValue([
|
||||
createSearchResult({ id: 1, text: 'hello world', sender_name: 'Alice' }),
|
||||
createSearchResult({ id: 2, text: 'hello there', sender_name: 'Bob' }),
|
||||
]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('hello');
|
||||
|
||||
// Text is split by highlightMatch into segments, so use container text content
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const texts = buttons.map((b) => b.textContent);
|
||||
expect(texts.some((t) => t?.includes('hello world') || t?.includes('world'))).toBe(true);
|
||||
expect(texts.some((t) => t?.includes('hello there') || t?.includes('there'))).toBe(true);
|
||||
});
|
||||
|
||||
it('shows no-results message when search returns empty', async () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('nonexistent');
|
||||
|
||||
expect(screen.getByText(/No messages found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to message on click', async () => {
|
||||
const result = createSearchResult({
|
||||
id: 42,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'ABC123',
|
||||
text: 'click me',
|
||||
});
|
||||
mockGetMessages.mockResolvedValue([result]);
|
||||
const onNavigate = vi.fn();
|
||||
|
||||
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
|
||||
|
||||
await typeAndWaitForResults('click');
|
||||
|
||||
const resultBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('me'));
|
||||
expect(resultBtn).toBeDefined();
|
||||
|
||||
fireEvent.click(resultBtn!);
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledWith({
|
||||
id: 42,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'ABC123',
|
||||
conversation_name: 'Public',
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates on Enter key', async () => {
|
||||
mockGetMessages.mockResolvedValue([createSearchResult({ id: 10, text: 'keyboard nav' })]);
|
||||
const onNavigate = vi.fn();
|
||||
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
|
||||
|
||||
await typeAndWaitForResults('keyboard');
|
||||
|
||||
const resultEl = screen.getByRole('button', { name: /keyboard nav/i });
|
||||
fireEvent.keyDown(resultEl, { key: 'Enter' });
|
||||
|
||||
expect(onNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows load more button when results fill a page', async () => {
|
||||
const pageResults = Array.from({ length: 50 }, (_, i) =>
|
||||
createSearchResult({ id: i + 1, text: `result ${i}` })
|
||||
);
|
||||
mockGetMessages.mockResolvedValueOnce(pageResults);
|
||||
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('result');
|
||||
|
||||
expect(screen.getByText('Load more results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show load more when results are less than page size', async () => {
|
||||
mockGetMessages.mockResolvedValue([createSearchResult({ id: 1, text: 'only one' })]);
|
||||
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('only');
|
||||
|
||||
const resultBtns = screen.getAllByRole('button');
|
||||
expect(resultBtns.some((b) => b.textContent?.includes('one'))).toBe(true);
|
||||
expect(screen.queryByText('Load more results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resolves channel name from channels prop', async () => {
|
||||
mockGetMessages.mockResolvedValue([
|
||||
createSearchResult({ id: 1, type: 'CHAN', conversation_key: 'ABC123', text: 'test' }),
|
||||
]);
|
||||
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('test');
|
||||
|
||||
expect(screen.getByText('Public')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resolves contact name from contacts prop', async () => {
|
||||
const contactKey = 'aa'.repeat(32);
|
||||
mockGetMessages.mockResolvedValue([
|
||||
createSearchResult({
|
||||
id: 1,
|
||||
type: 'PRIV',
|
||||
conversation_key: contactKey,
|
||||
text: 'dm test',
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<SearchView
|
||||
{...defaultProps}
|
||||
contacts={[
|
||||
{
|
||||
public_key: contactKey,
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
first_seen: null,
|
||||
last_read_at: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
await typeAndWaitForResults('dm');
|
||||
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,12 @@ import { useConversationMessages } from '../hooks/useConversationMessages';
|
||||
import type { Conversation, Message } from '../types';
|
||||
|
||||
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
|
||||
const mockGetMessagesAround = vi.fn();
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getMessages: (...args: unknown[]) => mockGetMessages(...args),
|
||||
getMessagesAround: (...args: unknown[]) => mockGetMessagesAround(...args),
|
||||
},
|
||||
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
|
||||
}));
|
||||
@@ -35,6 +37,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -219,3 +222,173 @@ describe('useConversationMessages conversation switch', () => {
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConversationMessages forward pagination', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessages.mockReset();
|
||||
mockGetMessagesAround.mockReset();
|
||||
messageCache.clear();
|
||||
});
|
||||
|
||||
it('fetchNewerMessages loads newer messages and appends them', async () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
|
||||
|
||||
// Initial load returns messages that indicate there are newer ones
|
||||
// (we'll set hasNewerMessages via targetMessageId + getMessagesAround)
|
||||
const initialMessages = Array.from({ length: 3 }, (_, i) =>
|
||||
createMessage({
|
||||
id: i + 1,
|
||||
conversation_key: 'ch1',
|
||||
text: `msg-${i}`,
|
||||
sender_timestamp: 1700000000 + i,
|
||||
received_at: 1700000000 + i,
|
||||
})
|
||||
);
|
||||
|
||||
mockGetMessagesAround.mockResolvedValueOnce({
|
||||
messages: initialMessages,
|
||||
has_older: false,
|
||||
has_newer: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
({ conv, target }: { conv: Conversation; target: number | null }) =>
|
||||
useConversationMessages(conv, target),
|
||||
{ initialProps: { conv, target: 2 } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.hasNewerMessages).toBe(true);
|
||||
|
||||
// Now fetch newer messages
|
||||
const newerMessages = [
|
||||
createMessage({
|
||||
id: 4,
|
||||
conversation_key: 'ch1',
|
||||
text: 'msg-3',
|
||||
sender_timestamp: 1700000003,
|
||||
received_at: 1700000003,
|
||||
}),
|
||||
];
|
||||
mockGetMessages.mockResolvedValueOnce(newerMessages);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNewerMessages();
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(4);
|
||||
// Less than page size → no more newer messages
|
||||
expect(result.current.hasNewerMessages).toBe(false);
|
||||
});
|
||||
|
||||
it('fetchNewerMessages deduplicates against seen messages', async () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
|
||||
|
||||
const initialMessages = [
|
||||
createMessage({
|
||||
id: 1,
|
||||
conversation_key: 'ch1',
|
||||
text: 'msg-0',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000000,
|
||||
}),
|
||||
];
|
||||
|
||||
mockGetMessagesAround.mockResolvedValueOnce({
|
||||
messages: initialMessages,
|
||||
has_older: false,
|
||||
has_newer: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
({ conv, target }: { conv: Conversation; target: number | null }) =>
|
||||
useConversationMessages(conv, target),
|
||||
{ initialProps: { conv, target: 1 } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
|
||||
// Simulate WS adding a message with the same content key
|
||||
act(() => {
|
||||
result.current.addMessageIfNew(
|
||||
createMessage({
|
||||
id: 2,
|
||||
conversation_key: 'ch1',
|
||||
text: 'duplicate-content',
|
||||
sender_timestamp: 1700000001,
|
||||
received_at: 1700000001,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// fetchNewerMessages returns the same content (different id but same content key)
|
||||
mockGetMessages.mockResolvedValueOnce([
|
||||
createMessage({
|
||||
id: 3,
|
||||
conversation_key: 'ch1',
|
||||
text: 'duplicate-content',
|
||||
sender_timestamp: 1700000001,
|
||||
received_at: 1700000001,
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNewerMessages();
|
||||
});
|
||||
|
||||
// Should not have a duplicate
|
||||
const dupes = result.current.messages.filter((m) => m.text === 'duplicate-content');
|
||||
expect(dupes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('jumpToBottom clears hasNewerMessages and refetches latest', async () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
|
||||
|
||||
const aroundMessages = [
|
||||
createMessage({
|
||||
id: 5,
|
||||
conversation_key: 'ch1',
|
||||
text: 'around-msg',
|
||||
sender_timestamp: 1700000005,
|
||||
received_at: 1700000005,
|
||||
}),
|
||||
];
|
||||
|
||||
mockGetMessagesAround.mockResolvedValueOnce({
|
||||
messages: aroundMessages,
|
||||
has_older: true,
|
||||
has_newer: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
({ conv, target }: { conv: Conversation; target: number | null }) =>
|
||||
useConversationMessages(conv, target),
|
||||
{ initialProps: { conv, target: 5 } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.hasNewerMessages).toBe(true);
|
||||
|
||||
// Jump to bottom
|
||||
const latestMessages = [
|
||||
createMessage({
|
||||
id: 10,
|
||||
conversation_key: 'ch1',
|
||||
text: 'latest-msg',
|
||||
sender_timestamp: 1700000010,
|
||||
received_at: 1700000010,
|
||||
}),
|
||||
];
|
||||
mockGetMessages.mockResolvedValueOnce(latestMessages);
|
||||
|
||||
act(() => {
|
||||
result.current.jumpToBottom();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.hasNewerMessages).toBe(false);
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].text).toBe('latest-msg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user