Skip to main content
Glama
ThreadChat.test.tsx18 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { ProfileResource, TypedEventTarget } from '@medplum/core'; import { createReference, getReferenceString } from '@medplum/core'; import type { Bundle, Communication, Reference } from '@medplum/fhirtypes'; import { BartSimpson, DrAliceSmith, HomerSimpson, MockClient } from '@medplum/mock'; // @ts-expect-error _subscriptionController is not exported from module normally // eslint-disable-next-line import/named import { MedplumProvider, _subscriptionController } from '@medplum/react-hooks'; import crypto from 'node:crypto'; import { MemoryRouter } from 'react-router'; import { act, fireEvent, render, screen } from '../../test-utils/render'; import type { ThreadChatProps } from './ThreadChat'; import { ThreadChat } from './ThreadChat'; type SubscriptionControllerEvents = { subscription: { type: 'subscription'; criteria: string; bundle: Bundle }; }; jest.mock('@medplum/react-hooks', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { TypedEventTarget } = require('@medplum/core'); const _subscriptionController = new TypedEventTarget() as TypedEventTarget<SubscriptionControllerEvents>; const original = jest.requireActual('@medplum/react-hooks'); return { ...original, useSubscription: jest.fn().mockImplementation((criteria: string, callback: (bundle: Bundle) => void) => { _subscriptionController.addEventListener('subscription', (event) => { if (criteria === event.criteria) { callback(event.bundle); } }); }), _subscriptionController, }; }); const homerReference = createReference(HomerSimpson); const drAliceReference = createReference(DrAliceSmith); const bartReference = createReference(BartSimpson); async function createThreadMessage( medplum: MockClient, threadRef: Reference<Communication>, communicationProps?: Partial<Communication> ): Promise<Communication> { const communication = { id: crypto.randomUUID(), resourceType: 'Communication', sender: createReference(medplum.getProfile() as ProfileResource), recipient: [homerReference], sent: new Date().toISOString(), status: 'in-progress', payload: [{ contentString: 'Hello, Medplum!' }], partOf: [threadRef], ...communicationProps, } satisfies Communication; return medplum.createResource(communication); } async function createThreadHeader( medplum: MockClient, threadHeaderProps?: Partial<Communication> ): Promise<Communication> { const threadHeader = { id: crypto.randomUUID(), resourceType: 'Communication', recipient: [drAliceReference, homerReference], status: 'in-progress', ...threadHeaderProps, } satisfies Communication; return medplum.createResource(threadHeader); } async function createThreadMessageSubBundle( medplum: MockClient, threadRef: Reference<Communication>, communication?: Communication ): Promise<Bundle> { communication ??= await createThreadMessage(medplum, threadRef); return { id: crypto.randomUUID(), resourceType: 'Bundle', type: 'history', timestamp: new Date().toISOString(), entry: [ { resource: { id: crypto.randomUUID(), resourceType: 'SubscriptionStatus', status: 'active', type: 'event-notification', subscription: { reference: 'Subscription/abc123' }, notificationEvent: [ { eventNumber: '0', timestamp: new Date().toISOString(), focus: createReference(communication), }, ], }, }, { resource: communication, fullUrl: `https://api.medplum.com/fhir/R4/Communication/${communication.id as string}`, }, ], }; } describe('ThreadChat', () => { let defaultMedplum: MockClient; let defaultThread: Communication; let defaultThreadRef: Reference<Communication>; beforeAll(async () => { defaultMedplum = new MockClient({ profile: DrAliceSmith }); defaultThread = await createThreadHeader(defaultMedplum); defaultThreadRef = createReference(defaultThread); await Promise.all([ createThreadMessage(defaultMedplum, defaultThreadRef, { payload: [{ contentString: 'Hello, Homer. How are you feeling?' }], }), createThreadMessage(defaultMedplum, defaultThreadRef, { sender: homerReference, recipient: [drAliceReference], payload: [ { contentString: "Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?", }, ], }), ]); }); afterEach(() => { (_subscriptionController as TypedEventTarget<SubscriptionControllerEvents>).removeAllListeners(); }); async function setup( props: ThreadChatProps, medplum?: MockClient ): Promise<{ rerender: (props: ThreadChatProps) => Promise<void> }> { const { rerender: _rerender } = await act(async () => render(<ThreadChat {...props} />, ({ children }) => ( <MemoryRouter> <MedplumProvider medplum={medplum ?? defaultMedplum}>{children}</MedplumProvider> </MemoryRouter> )) ); return { rerender: async (props: ThreadChatProps) => { await act(async () => _rerender(<ThreadChat {...props} />)); }, }; } test('Displays existing and incoming thread messages', async () => { const threadProps = { title: 'Test Chat', thread: defaultThread } satisfies ThreadChatProps; await setup(threadProps, defaultMedplum); // Displays existing messages expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); expect(screen.getByText('Hello, Homer. How are you feeling?')).toBeInTheDocument(); expect( screen.getByText("Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?") ).toBeInTheDocument(); const message = await createThreadMessage(defaultMedplum, defaultThreadRef, { payload: [{ contentString: 'Homer, this is a text chat.' }], }); const bundle = await createThreadMessageSubBundle(defaultMedplum, defaultThreadRef, message); act(() => { (_subscriptionController as TypedEventTarget<SubscriptionControllerEvents>).dispatchEvent({ type: 'subscription', criteria: `Communication?part-of=${getReferenceString(defaultThread)}`, bundle, }); }); // Displays incoming message expect(screen.getByText('Homer, this is a text chat.')).toBeInTheDocument(); }); test('Add delivered timestamps when ONLY 2 participants', async () => { const thread = await createThreadHeader(defaultMedplum); const threadRef = createReference(thread); await Promise.all([ createThreadMessage(defaultMedplum, threadRef, { payload: [{ contentString: 'Hello, Homer. How are you feeling?' }], }), createThreadMessage(defaultMedplum, threadRef, { sender: homerReference, recipient: [drAliceReference], payload: [ { contentString: "Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?", }, ], }), ]); const threadProps = { title: 'Test Chat', thread } satisfies ThreadChatProps; await setup(threadProps, defaultMedplum); // Displays existing messages expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); expect(screen.getByText('Hello, Homer. How are you feeling?')).toBeInTheDocument(); expect( screen.getByText("Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?") ).toBeInTheDocument(); const message = await createThreadMessage(defaultMedplum, threadRef, { sender: homerReference, recipient: [drAliceReference], payload: [{ contentString: "Doc, don't worry about me, I'm fine now. I had a donut and feel better" }], }); expect(message.received).not.toBeDefined(); const subBundle1 = await createThreadMessageSubBundle(defaultMedplum, threadRef, message); act(() => { (_subscriptionController as TypedEventTarget<SubscriptionControllerEvents>).dispatchEvent({ type: 'subscription', criteria: `Communication?part-of=${getReferenceString(thread)}`, bundle: subBundle1, }); }); const updatedMessage = await defaultMedplum.readResource('Communication', message.id as string); expect(updatedMessage.received).toBeDefined(); expect(updatedMessage.status).toEqual('completed'); const subBundle2 = await createThreadMessageSubBundle(defaultMedplum, threadRef, updatedMessage); act(() => { (_subscriptionController as TypedEventTarget<SubscriptionControllerEvents>).dispatchEvent({ type: 'subscription', criteria: `Communication?part-of=${getReferenceString(thread)}`, bundle: subBundle2, }); }); expect( screen.getByText("Doc, don't worry about me, I'm fine now. I had a donut and feel better") ).toBeInTheDocument(); }); test("Don't add delivered timestamps when MORE THAN 2 participants in chat", async () => { const thread = await createThreadHeader(defaultMedplum, { recipient: [homerReference, bartReference, drAliceReference], }); const threadRef = createReference(thread); await Promise.all([ createThreadMessage(defaultMedplum, threadRef, { recipient: [homerReference, bartReference], payload: [{ contentString: 'Hello, Homer. How are you feeling?' }], }), createThreadMessage(defaultMedplum, threadRef, { sender: homerReference, recipient: [drAliceReference, bartReference], payload: [ { contentString: "Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?", }, ], }), ]); const threadProps = { title: 'Test Chat', thread } satisfies ThreadChatProps; await setup(threadProps, defaultMedplum); // Displays existing messages expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); expect(screen.getByText('Hello, Homer. How are you feeling?')).toBeInTheDocument(); expect( screen.getByText("Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?") ).toBeInTheDocument(); const message = await createThreadMessage(defaultMedplum, threadRef, { sender: homerReference, recipient: [drAliceReference, bartReference], payload: [{ contentString: "Doc, don't worry about me, I'm fine now. I had a donut and feel better" }], }); expect(message.received).not.toBeDefined(); const bundle = await createThreadMessageSubBundle(defaultMedplum, threadRef, message); act(() => { (_subscriptionController as TypedEventTarget<SubscriptionControllerEvents>).dispatchEvent({ type: 'subscription', criteria: `Communication?part-of=${getReferenceString(thread)}`, bundle, }); }); const updatedMessage = await defaultMedplum.readResource('Communication', message.id as string); expect(updatedMessage.received).not.toBeDefined(); expect(updatedMessage.status).toEqual('in-progress'); }); test('Delivered timestamps show up when other participant has received chat', async () => { const thread = await createThreadHeader(defaultMedplum); const threadRef = createReference(thread); await Promise.all([ createThreadMessage(defaultMedplum, threadRef, { payload: [{ contentString: 'Hello, Homer. How are you feeling?' }], received: new Date().toISOString(), }), createThreadMessage(defaultMedplum, threadRef, { sender: homerReference, recipient: [drAliceReference], payload: [ { contentString: "Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?", }, ], }), ]); const threadProps = { title: 'Test Chat', thread } satisfies ThreadChatProps; await setup(threadProps, defaultMedplum); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); expect(screen.getByText('Hello, Homer. How are you feeling?')).toBeInTheDocument(); expect( screen.getByText("Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?") ).toBeInTheDocument(); expect(screen.getByText(/Delivered \d+:\d+/)).toBeInTheDocument(); }); test('Clears messages if given a new thread', async () => { const thread1 = await createThreadHeader(defaultMedplum); const thread1Ref = createReference(thread1); const thread2 = await createThreadHeader(defaultMedplum); const thread2Ref = createReference(thread2); await Promise.all([ createThreadMessage(defaultMedplum, thread1Ref, { payload: [{ contentString: 'Hello, Homer. How are you feeling?' }], }), createThreadMessage(defaultMedplum, thread1Ref, { sender: homerReference, recipient: [drAliceReference], payload: [ { contentString: "Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?", }, ], }), createThreadMessage(defaultMedplum, thread2Ref, { recipient: [bartReference], payload: [{ contentString: 'Hi, Bart. How are you feeling now?' }], }), createThreadMessage(defaultMedplum, thread2Ref, { sender: bartReference, recipient: [drAliceReference], payload: [ { contentString: 'Not so great, doc', }, ], }), ]); const threadProps1 = { title: 'Test Chat', thread: thread1 } satisfies ThreadChatProps; const { rerender } = await setup(threadProps1, defaultMedplum); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); expect(screen.getByText('Hello, Homer. How are you feeling?')).toBeInTheDocument(); expect( screen.getByText("Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?") ).toBeInTheDocument(); const threadProps2 = { title: 'Test Chat', thread: thread2 } satisfies ThreadChatProps; await rerender(threadProps2); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); expect(screen.queryByText('Hello, Homer. How are you feeling?')).not.toBeInTheDocument(); expect( screen.queryByText("Sorry doc, I can't hear you over the Geiger counter at the plant. Can you call back later?") ).not.toBeInTheDocument(); expect(screen.getByText('Hi, Bart. How are you feeling now?')).toBeInTheDocument(); expect(screen.getByText('Not so great, doc')).toBeInTheDocument(); }); test('Sending message', async () => { const thread = await createThreadHeader(defaultMedplum); const onMessageSent = jest.fn(); const threadProps = { title: 'Test Chat', thread, onMessageSent, } satisfies ThreadChatProps; await setup(threadProps, defaultMedplum); const input = screen.getByPlaceholderText('Type a message...'); expect(input).toBeInTheDocument(); act(() => { fireEvent.change(input, { target: { value: 'Hey, Homer. How are you feeling?' } }); }); act(() => { fireEvent.click(screen.getByRole('button', { name: 'Send message' })); }); expect(screen.getByPlaceholderText('Type a message...')).toHaveValue(''); expect(await screen.findByText('Hey, Homer. How are you feeling?')).toBeInTheDocument(); expect(onMessageSent).toHaveBeenCalledWith( expect.objectContaining<Communication>({ resourceType: 'Communication', payload: [{ contentString: 'Hey, Homer. How are you feeling?' }], status: 'in-progress', }) ); }); test('Not rendered when no profile', async () => { const medplum = new MockClient({ profile: null }); const thread = await createThreadHeader(medplum); const threadProps = { title: 'Test Chat', thread, } satisfies ThreadChatProps; await setup(threadProps, medplum); expect(screen.queryByPlaceholderText('Type a message...')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Close chat' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Open chat' })).not.toBeInTheDocument(); }); test('Title passed in', async () => { await setup({ title: 'Testing the title', thread: defaultThread }); expect(screen.getByText('Testing the title')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); }); test('No title passed in', async () => { const thread = await createThreadHeader(defaultMedplum, { topic: { text: 'Test Topic' } }); await setup({ thread }); expect(screen.getByText('Test Topic')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); }); test('No title passed in and no thread topic', async () => { await setup({ thread: defaultThread }); expect(screen.getByText('[No thread title]')).toBeInTheDocument(); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); }); test('inputDisabled', async () => { const threadProps = { thread: defaultThread }; const { rerender } = await setup({ ...threadProps }); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); await rerender({ ...threadProps, inputDisabled: false }); expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument(); await rerender({ ...threadProps, inputDisabled: true }); expect(screen.queryByPlaceholderText('Type a message...')).not.toBeInTheDocument(); }); });

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