// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import { renderHook, waitFor, act } from '@testing-library/react';
import { MedplumProvider } from '@medplum/react';
import type { JSX } from 'react';
import type { Communication } from '@medplum/fhirtypes';
import { MockClient } from '@medplum/mock';
import { describe, expect, test, beforeEach, vi } from 'vitest';
import { useThreadInbox } from './useThreadInbox';
import type { WithId } from '@medplum/core';
const mockCommunication1: Communication = {
resourceType: 'Communication',
id: 'comm-1',
status: 'completed',
sent: '2024-01-01T10:00:00Z',
payload: [{ contentString: 'First message' }],
};
const mockCommunication2: Communication = {
resourceType: 'Communication',
id: 'comm-2',
status: 'completed',
sent: '2024-01-01T11:00:00Z',
payload: [{ contentString: 'Second message' }],
partOf: [{ reference: 'Communication/comm-1' }],
};
const mockCommunication3: Communication = {
resourceType: 'Communication',
id: 'comm-3',
status: 'completed',
sent: '2024-01-01T12:00:00Z',
payload: [{ contentString: 'Third message' }],
partOf: [{ reference: 'Communication/comm-1' }],
};
describe('useThreadInbox', () => {
let medplum: MockClient;
beforeEach(async () => {
medplum = new MockClient();
vi.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => (
<MedplumProvider medplum={medplum}>{children}</MedplumProvider>
);
test('returns initial loading state', () => {
const { result } = renderHook(() => useThreadInbox({ query: '', threadId: undefined }), { wrapper });
expect(result.current.loading).toBe(true);
expect(result.current.threadMessages).toEqual([]);
expect(result.current.selectedThread).toBeUndefined();
expect(result.current.error).toBeNull();
expect(result.current.total).toBeUndefined();
});
test('fetches thread messages and returns only one message per topic', async () => {
await medplum.createResource(mockCommunication1);
await medplum.createResource(mockCommunication2);
await medplum.createResource(mockCommunication3);
// Create additional messages for the same thread to test that only one is returned
const mockCommunication4: Communication = {
resourceType: 'Communication',
id: 'comm-4',
status: 'completed',
sent: '2024-01-01T13:00:00Z',
payload: [{ contentString: 'Fourth message' }],
partOf: [{ reference: 'Communication/comm-1' }],
};
await medplum.createResource(mockCommunication4);
const graphqlSpy = vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [mockCommunication4], // Only the most recent message, even though comm-2, comm-4 exist
},
} as any);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: undefined }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(graphqlSpy).toHaveBeenCalled();
await waitFor(() => {
expect(result.current.threadMessages).toHaveLength(1);
expect(result.current.threadMessages[0][0].id).toBe('comm-1');
expect(result.current.threadMessages[0][1]?.id).toBe('comm-4');
});
});
test('skips topics without messages', async () => {
const parentWithoutMessages: Communication = {
resourceType: 'Communication',
id: 'comm-no-replies',
status: 'completed',
sent: '2024-01-01T10:00:00Z',
payload: [{ contentString: 'Parent with no replies' }],
};
const parentWithMessages: Communication = {
resourceType: 'Communication',
id: 'comm-with-replies',
status: 'completed',
sent: '2024-01-01T11:00:00Z',
payload: [{ contentString: 'Parent with replies' }],
};
const replyMessage: Communication = {
resourceType: 'Communication',
id: 'comm-reply',
status: 'completed',
sent: '2024-01-01T12:00:00Z',
payload: [{ contentString: 'Reply message' }],
partOf: [{ reference: 'Communication/comm-with-replies' }],
};
await medplum.createResource(parentWithoutMessages);
await medplum.createResource(parentWithMessages);
await medplum.createResource(replyMessage);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_commnoreplies: [],
thread_commwithreplies: [replyMessage],
},
} as any);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: undefined }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await waitFor(() => {
expect(result.current.threadMessages).toHaveLength(1);
expect(result.current.threadMessages[0][0].id).toBe('comm-with-replies');
expect(result.current.threadMessages[0][1]?.id).toBe('comm-reply');
expect(result.current.threadMessages.find((t) => t[0].id === 'comm-no-replies')).toBeUndefined();
});
});
test('selects thread by threadId', async () => {
await medplum.createResource(mockCommunication1);
await medplum.createResource(mockCommunication2);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [mockCommunication2],
},
} as any);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: 'comm-1' }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await waitFor(() => {
expect(result.current.selectedThread?.id).toBe('comm-1');
});
});
test('reads thread from API when threadId not found in messages', async () => {
// Don't create the resource, so it won't be found in search
// This simulates a thread that exists but isn't in the current search results
const readSpy = vi.spyOn(medplum, 'readResource').mockResolvedValue(mockCommunication1 as WithId<Communication>);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: 'comm-1' }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await waitFor(() => {
expect(readSpy).toHaveBeenCalledWith('Communication', 'comm-1');
expect(result.current.selectedThread?.id).toBe('comm-1');
});
});
test('reads parent thread when reading child communication with partOf field', async () => {
const parentCommunication: Communication = {
resourceType: 'Communication',
id: 'comm-0',
status: 'completed',
sent: '2024-01-01T09:00:00Z',
payload: [{ contentString: 'Parent message' }],
};
const communicationWithPartOf: Communication = {
...mockCommunication1,
partOf: [{ reference: 'Communication/comm-0' }],
};
vi.spyOn(medplum, 'readResource').mockResolvedValue(communicationWithPartOf as WithId<Communication>);
const readReferenceSpy = vi.spyOn(medplum, 'readReference').mockResolvedValue(parentCommunication as any);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: 'comm-1' }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await waitFor(() => {
expect(readReferenceSpy).toHaveBeenCalledWith({ reference: 'Communication/comm-0' });
expect(result.current.selectedThread?.id).toBe('comm-0');
});
});
test('handles thread status update', async () => {
await medplum.createResource(mockCommunication1);
await medplum.createResource(mockCommunication2);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [mockCommunication2],
},
} as any);
const updatedCommunication: Communication = {
...mockCommunication1,
status: 'in-progress',
};
const updateSpy = vi
.spyOn(medplum, 'updateResource')
.mockResolvedValue(updatedCommunication as WithId<Communication>);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: 'comm-1' }), {
wrapper,
});
await waitFor(() => {
expect(result.current.selectedThread?.id).toBe('comm-1');
});
await result.current.handleThreadStatusChange('in-progress');
await waitFor(() => {
expect(updateSpy).toHaveBeenCalled();
expect(result.current.selectedThread?.status).toBe('in-progress');
// Verify threadMessages is also updated
expect(result.current.threadMessages[0][0].status).toBe('in-progress');
});
});
test('does not update status when no thread is selected', async () => {
const updateSpy = vi.spyOn(medplum, 'updateResource');
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: undefined }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await result.current.handleThreadStatusChange('in-progress');
expect(updateSpy).not.toHaveBeenCalled();
});
test('handles update errors gracefully', async () => {
await medplum.createResource(mockCommunication1);
await medplum.createResource(mockCommunication2);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [mockCommunication2],
},
} as any);
const error = new Error('Update failed');
vi.spyOn(medplum, 'updateResource').mockRejectedValue(error);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: 'comm-1' }), {
wrapper,
});
await waitFor(() => {
expect(result.current.selectedThread?.id).toBe('comm-1');
});
await result.current.handleThreadStatusChange('in-progress');
await waitFor(() => {
expect(result.current.error).toBe(error);
});
});
test('adds new thread message', async () => {
const newMessage: Communication = {
resourceType: 'Communication',
id: 'comm-new',
status: 'completed',
sent: '2024-01-01T13:00:00Z',
payload: [{ contentString: 'New message' }],
};
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: undefined }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await act(async () => {
result.current.addThreadMessage(newMessage);
});
await waitFor(() => {
expect(result.current.threadMessages).toHaveLength(1);
expect(result.current.threadMessages[0][0].id).toBe('comm-new');
expect(result.current.threadMessages[0][1]).toBeUndefined();
});
});
test('handles search errors gracefully', async () => {
const error = new Error('Search failed');
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(medplum, 'search').mockRejectedValue(error);
const { result } = renderHook(() => useThreadInbox({ query: 'status=completed', threadId: undefined }), {
wrapper,
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(error);
});
consoleErrorSpy.mockRestore();
});
test('includes pagination parameters in search request', async () => {
const searchSpy = vi.spyOn(medplum, 'search').mockResolvedValue({
resourceType: 'Bundle',
type: 'searchset',
total: 50,
entry: [],
} as any);
const { result } = renderHook(
() => useThreadInbox({ query: 'status=completed', threadId: undefined, offset: 20, count: 20 }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(searchSpy).toHaveBeenCalledWith('Communication', expect.stringContaining('_offset=20'), expect.any(Object));
expect(searchSpy).toHaveBeenCalledWith('Communication', expect.stringContaining('_count=20'), expect.any(Object));
expect(searchSpy).toHaveBeenCalledWith(
'Communication',
expect.stringContaining('_total=accurate'),
expect.any(Object)
);
});
test('returns total count from bundle', async () => {
await medplum.createResource(mockCommunication1);
await medplum.createResource(mockCommunication2);
vi.spyOn(medplum, 'search').mockResolvedValue({
resourceType: 'Bundle',
type: 'searchset',
total: 100,
entry: [{ resource: mockCommunication1 }, { resource: mockCommunication2 }],
} as any);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [mockCommunication2],
},
} as any);
const { result } = renderHook(
() => useThreadInbox({ query: 'status=completed', threadId: undefined, offset: 0, count: 20 }),
{ wrapper }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
await waitFor(() => {
expect(result.current.total).toBe(100);
});
});
test('handles pagination with offset and count', async () => {
const comm1: Communication = {
resourceType: 'Communication',
id: 'comm-1',
status: 'completed',
sent: '2024-01-01T10:00:00Z',
payload: [{ contentString: 'Message 1' }],
};
const comm2: Communication = {
resourceType: 'Communication',
id: 'comm-2',
status: 'completed',
sent: '2024-01-01T11:00:00Z',
payload: [{ contentString: 'Message 2' }],
};
const comm3: Communication = {
resourceType: 'Communication',
id: 'comm-3',
status: 'completed',
sent: '2024-01-01T12:00:00Z',
payload: [{ contentString: 'Message 3' }],
};
await medplum.createResource(comm1);
await medplum.createResource(comm2);
await medplum.createResource(comm3);
// First page - offset 0, count 2
vi.spyOn(medplum, 'search').mockResolvedValue({
resourceType: 'Bundle',
type: 'searchset',
total: 3,
entry: [{ resource: comm1 }, { resource: comm2 }],
} as any);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [comm1],
thread_comm2: [comm2],
},
} as any);
const { result, rerender } = renderHook(
({ offset, count }) => useThreadInbox({ query: 'status=completed', threadId: undefined, offset, count }),
{
wrapper,
initialProps: { offset: 0, count: 2 },
}
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.total).toBe(3);
expect(result.current.threadMessages).toHaveLength(2);
});
// Second page - offset 2, count 2
vi.spyOn(medplum, 'search').mockResolvedValue({
resourceType: 'Bundle',
type: 'searchset',
total: 3,
entry: [{ resource: comm3 }],
} as any);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm3: [comm3],
},
} as any);
rerender({ offset: 2, count: 2 });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.threadMessages).toHaveLength(1);
expect(result.current.threadMessages[0][0].id).toBe('comm-3');
});
});
test('resets pagination when query changes', async () => {
await medplum.createResource(mockCommunication1);
vi.spyOn(medplum, 'search').mockResolvedValue({
resourceType: 'Bundle',
type: 'searchset',
total: 1,
entry: [{ resource: mockCommunication1 }],
} as any);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [mockCommunication1],
},
} as any);
const { result, rerender } = renderHook(
({ query, offset, count }) => useThreadInbox({ query, threadId: undefined, offset, count }),
{
wrapper,
initialProps: { query: 'status=completed', offset: 20, count: 20 },
}
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Change query - should trigger new search
rerender({ query: 'status=in-progress', offset: 20, count: 20 });
await waitFor(() => {
expect(medplum.search).toHaveBeenCalledWith(
'Communication',
expect.stringContaining('status=in-progress'),
expect.any(Object)
);
});
});
test('clears selected thread when threadId becomes undefined', async () => {
await medplum.createResource(mockCommunication1);
await medplum.createResource(mockCommunication2);
vi.spyOn(medplum, 'graphql').mockResolvedValue({
data: {
thread_comm1: [mockCommunication2],
},
} as any);
const { result, rerender } = renderHook(({ threadId }) => useThreadInbox({ query: 'status=completed', threadId }), {
wrapper,
initialProps: { threadId: 'comm-1' as string | undefined },
});
await waitFor(() => {
expect(result.current.selectedThread?.id).toBe('comm-1');
});
rerender({ threadId: undefined });
await waitFor(() => {
expect(result.current.selectedThread).toBeUndefined();
});
});
});