mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Patch up some missing tests and fix+test channel add not clearing on channel submission without add-another checked
This commit is contained in:
@@ -48,6 +48,15 @@ export function NewMessageModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setRoomKey('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
@@ -77,6 +86,7 @@ export function NewMessageModal({
|
||||
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
|
||||
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
|
||||
}
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create');
|
||||
@@ -121,7 +131,15 @@ export function NewMessageModal({
|
||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
@@ -137,8 +155,7 @@ export function NewMessageModal({
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(v as Tab);
|
||||
setName('');
|
||||
setError('');
|
||||
resetForm();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
@@ -164,6 +181,7 @@ export function NewMessageModal({
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
@@ -294,7 +312,13 @@ export function NewMessageModal({
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{tab === 'hashtag' && (
|
||||
|
||||
@@ -113,8 +113,8 @@ export function SettingsRadioSection({
|
||||
const parsedCr = parseInt(cr, 10);
|
||||
|
||||
if (
|
||||
[parsedLat, parsedLon, parsedTxPower, parsedFreq, parsedBw, parsedSf, parsedCr].some(
|
||||
(v) => isNaN(v)
|
||||
[parsedLat, parsedLon, parsedTxPower, parsedFreq, parsedBw, parsedSf, parsedCr].some((v) =>
|
||||
isNaN(v)
|
||||
)
|
||||
) {
|
||||
setError('All numeric fields must have valid values');
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Tests for MessageInput component.
|
||||
*
|
||||
* Verifies character/byte limit calculation, warning states, and send button
|
||||
* behavior for both DM and channel conversations.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { MessageInput } from '../components/MessageInput';
|
||||
|
||||
// Mock sonner (toast)
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function byteLen(s: string): number {
|
||||
return textEncoder.encode(s).length;
|
||||
}
|
||||
|
||||
describe('MessageInput', () => {
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderInput(props: {
|
||||
conversationType?: 'contact' | 'channel' | 'raw';
|
||||
senderName?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return render(
|
||||
<MessageInput
|
||||
onSend={onSend}
|
||||
disabled={props.disabled ?? false}
|
||||
conversationType={props.conversationType}
|
||||
senderName={props.senderName}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getInput() {
|
||||
return screen.getByPlaceholderText('Type a message...') as HTMLInputElement;
|
||||
}
|
||||
|
||||
function getSendButton() {
|
||||
return screen.getByRole('button', { name: /send/i }) as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe('send button state', () => {
|
||||
it('is disabled when text is empty', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
expect(getSendButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is enabled when text is entered', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
fireEvent.change(getInput(), { target: { value: 'Hello' } });
|
||||
expect(getSendButton()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('is disabled when whitespace-only', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
fireEvent.change(getInput(), { target: { value: ' ' } });
|
||||
expect(getSendButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
renderInput({ conversationType: 'contact', disabled: true });
|
||||
fireEvent.change(getInput(), { target: { value: 'Hello' } });
|
||||
expect(getSendButton()).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('byte counter display', () => {
|
||||
it('shows byte counter for DM conversations', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
fireEvent.change(getInput(), { target: { value: 'Hello' } });
|
||||
|
||||
// Should show "5/156" somewhere (DM hard limit = 156)
|
||||
expect(screen.getByText(/5\/156/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows byte counter for channel conversations', () => {
|
||||
renderInput({ conversationType: 'channel', senderName: 'MyNode' });
|
||||
fireEvent.change(getInput(), { target: { value: 'Hello' } });
|
||||
|
||||
// Channel hard limit = 156 - byteLen("MyNode") - 2 = 156 - 6 - 2 = 148
|
||||
expect(screen.getByText(/5\/148/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show byte counter for raw conversations', () => {
|
||||
renderInput({ conversationType: 'raw' });
|
||||
fireEvent.change(getInput(), { target: { value: 'Hello' } });
|
||||
|
||||
// No counter should be visible
|
||||
expect(screen.queryByText(/\/\d+/)).toBeNull();
|
||||
});
|
||||
|
||||
it('accounts for multi-byte characters in byte count', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
// Emoji: "🥝" is 4 bytes in UTF-8
|
||||
fireEvent.change(getInput(), { target: { value: '🥝' } });
|
||||
const bytes = byteLen('🥝'); // Should be 4
|
||||
expect(bytes).toBe(4);
|
||||
expect(screen.getByText(new RegExp(`${bytes}/156`))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel limit adjusts for sender name', () => {
|
||||
it('reduces limit based on sender name byte length', () => {
|
||||
// Sender name "LongNodeName" = 12 bytes + 2 for ": " = 14 overhead
|
||||
// Hard limit = 156 - 14 = 142
|
||||
renderInput({ conversationType: 'channel', senderName: 'LongNodeName' });
|
||||
fireEvent.change(getInput(), { target: { value: 'x' } });
|
||||
expect(screen.getByText(/1\/142/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses default 10-byte name when sender name is absent', () => {
|
||||
// Default: 10 bytes + 2 = 12 overhead. Hard limit = 156 - 12 = 144
|
||||
renderInput({ conversationType: 'channel' });
|
||||
fireEvent.change(getInput(), { target: { value: 'x' } });
|
||||
expect(screen.getByText(/1\/144/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles multi-byte sender names correctly', () => {
|
||||
// "🥝Node" = 4 + 4 = 8 bytes name + 2 separator = 10 overhead
|
||||
// Hard limit = 156 - 10 = 146
|
||||
const senderName = '🥝Node';
|
||||
const nameBytes = byteLen(senderName);
|
||||
const expectedLimit = 156 - nameBytes - 2;
|
||||
renderInput({ conversationType: 'channel', senderName });
|
||||
fireEvent.change(getInput(), { target: { value: 'x' } });
|
||||
expect(screen.getByText(new RegExp(`1/${expectedLimit}`))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('warning states', () => {
|
||||
it('shows warning text when exceeding DM warning threshold', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
// DM warning threshold = 140 bytes
|
||||
const text = 'x'.repeat(141);
|
||||
fireEvent.change(getInput(), { target: { value: text } });
|
||||
// Rendered in both desktop and mobile variants
|
||||
expect(screen.getAllByText(/may impact multi-repeater hop delivery/).length).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('shows truncation warning when exceeding DM hard limit', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
// DM hard limit = 156 bytes
|
||||
const text = 'x'.repeat(157);
|
||||
fireEvent.change(getInput(), { target: { value: text } });
|
||||
// Rendered in both desktop and mobile variants
|
||||
expect(screen.getAllByText(/likely truncated by radio/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows no warning for short messages', () => {
|
||||
renderInput({ conversationType: 'contact' });
|
||||
fireEvent.change(getInput(), { target: { value: 'Hello' } });
|
||||
expect(screen.queryByText(/truncated/)).toBeNull();
|
||||
expect(screen.queryByText(/may impact/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('send button remains enabled past hard limit (current behavior)', () => {
|
||||
it('does not disable send button when over hard limit', () => {
|
||||
// NOTE: This documents the current behavior where canSubmit only checks
|
||||
// text.trim().length > 0, NOT the limit state. This is related to
|
||||
// hitlist item 1.1 — the send button stays enabled even over the limit.
|
||||
renderInput({ conversationType: 'contact' });
|
||||
const text = 'x'.repeat(200); // Well over 156 byte limit
|
||||
fireEvent.change(getInput(), { target: { value: text } });
|
||||
|
||||
// Button is still enabled — canSubmit only checks non-empty text
|
||||
expect(getSendButton()).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Tests for NewMessageModal form state reset.
|
||||
*
|
||||
* Verifies that form fields are cleared when the modal closes (via Create,
|
||||
* Cancel, or Dialog dismiss) and when switching tabs.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { NewMessageModal } from '../components/NewMessageModal';
|
||||
import type { Contact } from '../types';
|
||||
|
||||
// Mock sonner (toast)
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Alice',
|
||||
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,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
describe('NewMessageModal form reset', () => {
|
||||
const onClose = vi.fn();
|
||||
const onSelectConversation = vi.fn();
|
||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderModal(open = true) {
|
||||
return render(
|
||||
<NewMessageModal
|
||||
open={open}
|
||||
contacts={[mockContact]}
|
||||
undecryptedCount={5}
|
||||
onClose={onClose}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onCreateContact={onCreateContact}
|
||||
onCreateChannel={onCreateChannel}
|
||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function switchToTab(user: ReturnType<typeof userEvent.setup>, name: string) {
|
||||
await user.click(screen.getByRole('tab', { name }));
|
||||
}
|
||||
|
||||
describe('hashtag tab', () => {
|
||||
it('clears name after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'testchan');
|
||||
expect(input.value).toBe('testchan');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreateHashtagChannel).toHaveBeenCalledWith('#testchan', false);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
// Re-render to simulate reopening — state should be reset
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('clears name when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'mychannel');
|
||||
expect(input.value).toBe('mychannel');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-contact tab', () => {
|
||||
it('clears name and key after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Contact');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
|
||||
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'bb'.repeat(32));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreateContact).toHaveBeenCalledWith('Bob', 'bb'.repeat(32), false);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-room tab', () => {
|
||||
it('clears name and key after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreateChannel).toHaveBeenCalledWith('MyRoom', 'cc'.repeat(16), false);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab switching resets form', () => {
|
||||
it('clears contact fields when switching to room tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Contact');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
|
||||
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
|
||||
|
||||
// Switch to Room tab — fields should reset
|
||||
await switchToTab(user, 'Room');
|
||||
|
||||
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
it('clears room fields when switching to hashtag tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
||||
|
||||
await switchToTab(user, 'Hashtag');
|
||||
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryHistorical checkbox resets', () => {
|
||||
it('resets tryHistorical when switching tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
|
||||
// Check the "Try decrypting" checkbox
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
|
||||
await user.click(checkbox);
|
||||
|
||||
// The streaming message should appear
|
||||
expect(screen.getByText(/Messages will stream in/)).toBeTruthy();
|
||||
|
||||
// Switch tab and come back
|
||||
await switchToTab(user, 'Contact');
|
||||
await switchToTab(user, 'Hashtag');
|
||||
|
||||
// The streaming message should be gone (tryHistorical was reset)
|
||||
expect(screen.queryByText(/Messages will stream in/)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Tests for useContactsAndChannels hook.
|
||||
*
|
||||
* Focuses on pagination logic in fetchAllContacts (which fetches 1000 items
|
||||
* per page and continues until a page returns fewer than pageSize results).
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
|
||||
import type { Contact } from '../types';
|
||||
|
||||
// Mock api module
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getContacts: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
createContact: vi.fn(),
|
||||
createChannel: vi.fn(),
|
||||
deleteContact: vi.fn(),
|
||||
deleteChannel: vi.fn(),
|
||||
decryptHistoricalPackets: vi.fn(),
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock prefetch — takePrefetchOrFetch calls the fetcher directly
|
||||
vi.mock('../prefetch', () => ({
|
||||
takePrefetchOrFetch: vi.fn((_key: string, fetcher: () => Promise<unknown>) => fetcher()),
|
||||
}));
|
||||
|
||||
// Mock sonner
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock messageCache
|
||||
vi.mock('../messageCache', () => ({
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeContact(suffix: string): Contact {
|
||||
const key = suffix.padStart(64, '0');
|
||||
return {
|
||||
public_key: key,
|
||||
name: `Contact-${suffix}`,
|
||||
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,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContacts(count: number, startIndex = 0): Contact[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeContact(String(startIndex + i).padStart(4, '0'))
|
||||
);
|
||||
}
|
||||
|
||||
describe('useContactsAndChannels', () => {
|
||||
const setActiveConversation = vi.fn();
|
||||
const pendingDeleteFallbackRef = { current: false };
|
||||
const hasSetDefaultConversation = { current: false };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pendingDeleteFallbackRef.current = false;
|
||||
hasSetDefaultConversation.current = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderUseContactsAndChannels() {
|
||||
return renderHook(() =>
|
||||
useContactsAndChannels({
|
||||
setActiveConversation,
|
||||
pendingDeleteFallbackRef,
|
||||
hasSetDefaultConversation,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('fetchAllContacts pagination', () => {
|
||||
it('returns contacts directly when fewer than page size', async () => {
|
||||
const { api } = await import('../api');
|
||||
const contacts = makeContacts(50);
|
||||
vi.mocked(api.getContacts).mockResolvedValueOnce(contacts);
|
||||
|
||||
const { result } = renderUseContactsAndChannels();
|
||||
|
||||
let fetched: Contact[] = [];
|
||||
await act(async () => {
|
||||
fetched = await result.current.fetchAllContacts();
|
||||
});
|
||||
|
||||
expect(fetched).toHaveLength(50);
|
||||
// Should only call once (no pagination needed)
|
||||
expect(api.getContacts).toHaveBeenCalledTimes(1);
|
||||
expect(api.getContacts).toHaveBeenCalledWith(1000, 0);
|
||||
});
|
||||
|
||||
it('paginates when first page returns exactly page size', async () => {
|
||||
const { api } = await import('../api');
|
||||
const page1 = makeContacts(1000, 0);
|
||||
const page2 = makeContacts(200, 1000);
|
||||
|
||||
vi.mocked(api.getContacts)
|
||||
.mockResolvedValueOnce(page1) // First page: full
|
||||
.mockResolvedValueOnce(page2); // Second page: partial (done)
|
||||
|
||||
const { result } = renderUseContactsAndChannels();
|
||||
|
||||
let fetched: Contact[] = [];
|
||||
await act(async () => {
|
||||
fetched = await result.current.fetchAllContacts();
|
||||
});
|
||||
|
||||
expect(fetched).toHaveLength(1200);
|
||||
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
||||
expect(api.getContacts).toHaveBeenNthCalledWith(1, 1000, 0);
|
||||
expect(api.getContacts).toHaveBeenNthCalledWith(2, 1000, 1000);
|
||||
});
|
||||
|
||||
it('paginates through multiple full pages', async () => {
|
||||
const { api } = await import('../api');
|
||||
const page1 = makeContacts(1000, 0);
|
||||
const page2 = makeContacts(1000, 1000);
|
||||
const page3 = makeContacts(500, 2000);
|
||||
|
||||
vi.mocked(api.getContacts)
|
||||
.mockResolvedValueOnce(page1)
|
||||
.mockResolvedValueOnce(page2)
|
||||
.mockResolvedValueOnce(page3);
|
||||
|
||||
const { result } = renderUseContactsAndChannels();
|
||||
|
||||
let fetched: Contact[] = [];
|
||||
await act(async () => {
|
||||
fetched = await result.current.fetchAllContacts();
|
||||
});
|
||||
|
||||
expect(fetched).toHaveLength(2500);
|
||||
expect(api.getContacts).toHaveBeenCalledTimes(3);
|
||||
expect(api.getContacts).toHaveBeenNthCalledWith(3, 1000, 2000);
|
||||
});
|
||||
|
||||
it('handles exactly page size total (boundary case)', async () => {
|
||||
const { api } = await import('../api');
|
||||
const page1 = makeContacts(1000, 0);
|
||||
const page2: Contact[] = []; // Empty second page
|
||||
|
||||
vi.mocked(api.getContacts).mockResolvedValueOnce(page1).mockResolvedValueOnce(page2);
|
||||
|
||||
const { result } = renderUseContactsAndChannels();
|
||||
|
||||
let fetched: Contact[] = [];
|
||||
await act(async () => {
|
||||
fetched = await result.current.fetchAllContacts();
|
||||
});
|
||||
|
||||
expect(fetched).toHaveLength(1000);
|
||||
expect(api.getContacts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user