Skip to main content
Glama
useThreadInbox.test.tsx17.3 kB
// 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(); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server