Skip to content

Commit

Permalink
Refactor types
Browse files Browse the repository at this point in the history
  • Loading branch information
fjsj committed Jan 8, 2025
1 parent 16b1ee2 commit 49586e5
Show file tree
Hide file tree
Showing 14 changed files with 364 additions and 207 deletions.
136 changes: 89 additions & 47 deletions __tests__/hooks/useChat-messages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Bundle, Communication, Patient, Practitioner } from "@medplum/fhirtypes
import { MockClient, MockSubscriptionManager } from "@medplum/mock";
import { MedplumProvider } from "@medplum/react-hooks";
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { when } from "jest-when";

import { ChatProvider, useChat } from "@/contexts/ChatContext";
import { getQueryString } from "@/utils/url";
Expand Down Expand Up @@ -80,7 +81,11 @@ async function createCommunicationSubBundle(communication: Communication): Promi
}

describe("useChat (messages)", () => {
async function setup(): Promise<{ medplum: MockClient; subManager: MockSubscriptionManager }> {
async function setup(): Promise<{
medplum: MockClient;
subManager: MockSubscriptionManager;
searchSpy: jest.SpyInstance;
}> {
const medplum = new MockClient({ profile: mockPatient });
const subManager = new MockSubscriptionManager(
medplum,
Expand All @@ -95,7 +100,36 @@ describe("useChat (messages)", () => {
await medplum.createResource(mockMessage1);
await medplum.createResource(mockMessage2);

return { medplum, subManager };
// Mock the search implementation to return results with search modes
// (the default mock client doesn't support search modes)
const searchSpy = jest.spyOn(medplum, "search");
when(searchSpy)
.calledWith(
"Communication",
expect.objectContaining({
"part-of:missing": true,
}),
expect.anything(),
)
.mockResolvedValue({
resourceType: "Bundle",
type: "searchset",
entry: [
{
search: { mode: "match" },
resource: mockThread,
},
{
search: { mode: "include" },
resource: mockMessage1,
},
{
search: { mode: "include" },
resource: mockMessage2,
},
],
});
return { medplum, subManager, searchSpy };
}

// Helper function to create wrapper with both providers
Expand Down Expand Up @@ -144,11 +178,11 @@ describe("useChat (messages)", () => {
});

// Check messages are displayed
expect(result.current.threadMessages).toHaveLength(2);
expect(result.current.threadMessages[0].text).toBe("Hello");
expect(result.current.threadMessages[0].senderType).toBe("Patient");
expect(result.current.threadMessages[1].text).toBe("Hi there");
expect(result.current.threadMessages[1].senderType).toBe("Practitioner");
expect(result.current.currentThread?.messages).toHaveLength(2);
expect(result.current.currentThread?.messages[0].text).toBe("Hello");
expect(result.current.currentThread?.messages[0].senderType).toBe("Patient");
expect(result.current.currentThread?.messages[1].text).toBe("Hi there");
expect(result.current.currentThread?.messages[1].senderType).toBe("Practitioner");
});

test("Sends new message", async () => {
Expand Down Expand Up @@ -200,8 +234,8 @@ describe("useChat (messages)", () => {
});

// Verify the new message appears in the list
expect(result.current.threadMessages[2].text).toBe("New message");
expect(result.current.threadMessages[2].senderType).toBe("Patient");
expect(result.current.currentThread?.messages[2].text).toBe("New message");
expect(result.current.currentThread?.messages[2].senderType).toBe("Patient");
});

test("Does not send empty message", async () => {
Expand Down Expand Up @@ -305,8 +339,8 @@ describe("useChat (messages)", () => {

// Verify the new message appears in real-time
await waitFor(() => {
expect(result.current.threadMessages).toHaveLength(3);
expect(result.current.threadMessages[2].text).toBe("Real-time incoming message");
expect(result.current.currentThread?.messages).toHaveLength(3);
expect(result.current.currentThread?.messages[2].text).toBe("Real-time incoming message");
});
});

Expand Down Expand Up @@ -364,8 +398,8 @@ describe("useChat (messages)", () => {

// Verify the message was updated
await waitFor(() => {
expect(result.current.threadMessages[1].text).toBe("Updated message");
expect(result.current.threadMessages).toHaveLength(2);
expect(result.current.currentThread?.messages[1].text).toBe("Updated message");
expect(result.current.currentThread?.messages).toHaveLength(2);
});
});

Expand All @@ -391,22 +425,18 @@ describe("useChat (messages)", () => {
});

// Verify initial messages are loaded
expect(result.current.threadMessages).toHaveLength(2);
expect(result.current.threadMessages[0].text).toBe("Hello");
expect(result.current.threadMessages[1].text).toBe("Hi there");
expect(result.current.currentThread?.messages).toHaveLength(2);
expect(result.current.currentThread?.messages[0].text).toBe("Hello");
expect(result.current.currentThread?.messages[1].text).toBe("Hi there");

// Change the profile
await act(async () => {
medplum.setProfile(mockOtherPatient);
});

// Wait for loading to complete with new profile
await waitFor(() => {
expect(result.current.isLoadingMessages).toBe(false);
});

// Verify messages are cleared
expect(result.current.threadMessages).toHaveLength(0);
// Verify state is reset
expect(result.current.currentThread).toBeNull();
expect(result.current.message).toBe("");
});

test("Handles WebSocket disconnection and reconnection", async () => {
Expand Down Expand Up @@ -466,7 +496,7 @@ describe("useChat (messages)", () => {
expect(onWebSocketOpenMock).toHaveBeenCalledTimes(1);

// New message should not be in chat yet
expect(result.current.threadMessages).not.toHaveLength(3);
expect(result.current.currentThread?.messages).not.toHaveLength(3);

// Emit subscription connected event
act(() => {
Expand All @@ -485,11 +515,11 @@ describe("useChat (messages)", () => {
// Wait for reconnection and message refresh
await waitFor(() => {
expect(result.current.isLoadingMessages).toBe(false);
expect(result.current.threadMessages).toHaveLength(3);
expect(result.current.currentThread?.messages).toHaveLength(3);
});

// Verify the new message was fetched after reconnection
expect(result.current.threadMessages[2].text).toBe("Message while disconnected");
expect(result.current.currentThread?.messages[2].text).toBe("Message while disconnected");
});

test("Calls onError callback when subscription error occurs", async () => {
Expand Down Expand Up @@ -519,12 +549,19 @@ describe("useChat (messages)", () => {

test("Calls onError on first load if search fails", async () => {
const onErrorMock = jest.fn();
const { medplum } = await setup();
const searchSpy = jest.spyOn(medplum, "searchResources");
const { medplum, searchSpy } = await setup();

// Mock search to throw an error
const error = new Error("Failed to load messages");
searchSpy.mockRejectedValue(error);
when(searchSpy)
.calledWith(
"Communication",
expect.objectContaining({
"part-of": "Communication/test-thread",
}),
expect.anything(),
)
.mockRejectedValue(error);

const { result } = renderHook(() => useChat(), {
wrapper: createWrapper(medplum, { onError: onErrorMock }),
Expand All @@ -546,9 +583,6 @@ describe("useChat (messages)", () => {
// Verify the onError callback was called with the error
expect(onErrorMock).toHaveBeenCalledWith(error);
expect(onErrorMock).toHaveBeenCalledTimes(1);

// Verify no messages were loaded
expect(result.current.threadMessages).toHaveLength(0);
});

test("New message starts with sent status only", async () => {
Expand Down Expand Up @@ -577,10 +611,12 @@ describe("useChat (messages)", () => {

// Wait for messages to update
await waitFor(() => {
const lastMessage = result.current.threadMessages[result.current.threadMessages.length - 1];
expect(lastMessage.sentAt).toBeDefined();
expect(lastMessage.received).toBeUndefined();
expect(lastMessage.read).toBe(false);
const lastMessage =
result.current.currentThread?.messages[result.current.currentThread?.messages.length - 1];
expect(lastMessage).toBeDefined();
expect(lastMessage!.sentAt).toBeDefined();
expect(lastMessage!.received).toBeUndefined();
expect(lastMessage!.read).toBe(false);
});
});

Expand Down Expand Up @@ -615,7 +651,7 @@ describe("useChat (messages)", () => {

// Verify the message in the list has received status
await waitFor(() => {
const message = result.current.threadMessages.find((m) => m.id === "msg-3");
const message = result.current.currentThread?.messages.find((m) => m.id === "msg-3");
expect(message?.received).toBeDefined();
expect(message?.read).toBe(false);
});
Expand Down Expand Up @@ -660,10 +696,12 @@ describe("useChat (messages)", () => {

// Verify received timestamp is set
await waitFor(() => {
const lastMessage = result.current.threadMessages[result.current.threadMessages.length - 1];
expect(lastMessage.text).toBe("Test received status");
expect(lastMessage.received).toBeDefined();
expect(lastMessage.read).toBe(false);
const lastMessage =
result.current.currentThread?.messages[result.current.currentThread?.messages.length - 1];
expect(lastMessage).toBeDefined();
expect(lastMessage!.text).toBe("Test received status");
expect(lastMessage!.received).toBeDefined();
expect(lastMessage!.read).toBe(false);
});
});

Expand All @@ -684,7 +722,7 @@ describe("useChat (messages)", () => {
});

// Get an unread message
const unreadMessage = result.current.threadMessages.find((m) => m.id === "msg-2");
const unreadMessage = result.current.currentThread?.messages.find((m) => m.id === "msg-2");
expect(unreadMessage).toBeDefined();

// Mark message as read
Expand All @@ -694,7 +732,9 @@ describe("useChat (messages)", () => {

// Verify message is marked as read
await waitFor(() => {
const message = result.current.threadMessages.find((m) => m.id === unreadMessage!.id);
const message = result.current.currentThread?.messages.find(
(m) => m.id === unreadMessage!.id,
);
expect(message?.read).toBe(true);
});
});
Expand Down Expand Up @@ -727,7 +767,7 @@ describe("useChat (messages)", () => {
// Wait for initial load
await waitFor(() => {
expect(result.current.isLoadingMessages).toBe(false);
const message = result.current.threadMessages.find((m) => m.id === newMessage.id);
const message = result.current.currentThread?.messages.find((m) => m.id === newMessage.id);
expect(message?.read).toBe(true);
});

Expand All @@ -738,7 +778,7 @@ describe("useChat (messages)", () => {

// Verify message is still marked as read
await waitFor(() => {
const message = result.current.threadMessages.find((m) => m.id === newMessage.id);
const message = result.current.currentThread?.messages.find((m) => m.id === newMessage.id);
expect(message?.read).toBe(true);
});
});
Expand All @@ -761,14 +801,16 @@ describe("useChat (messages)", () => {
});

// Try to mark an outgoing message as read
const unreadMessage = result.current.threadMessages.find((m) => m.id === "msg-1");
const unreadMessage = result.current.currentThread?.messages.find((m) => m.id === "msg-1");
await act(async () => {
await result.current.markMessageAsRead(unreadMessage!.id);
});

// Verify message is still marked as not read
await waitFor(() => {
const message = result.current.threadMessages.find((m) => m.id === unreadMessage!.id);
const message = result.current.currentThread?.messages.find(
(m) => m.id === unreadMessage!.id,
);
expect(message?.read).toBe(false);
});
});
Expand Down
36 changes: 24 additions & 12 deletions __tests__/hooks/useChat-threads.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,12 +177,18 @@ describe("useChat (threads)", () => {
});

// Verify search was called correctly
expect(searchSpy).toHaveBeenCalledWith("Communication", {
"part-of:missing": true,
subject: "Patient/test-patient",
_revinclude: "Communication:part-of",
_sort: "-sent",
});
expect(searchSpy).toHaveBeenCalledWith(
"Communication",
{
"part-of:missing": true,
subject: "Patient/test-patient",
_revinclude: "Communication:part-of",
_sort: "-sent",
},
{
cache: "no-cache",
},
);

// Check threads are displayed correctly
expect(result.current.threads).toHaveLength(2);
Expand Down Expand Up @@ -241,12 +247,18 @@ describe("useChat (threads)", () => {
expect(result.current.isLoadingThreads).toBe(false);
});

expect(searchSpy).toHaveBeenCalledWith("Communication", {
"part-of:missing": true,
subject: undefined,
_revinclude: "Communication:part-of",
_sort: "-sent",
});
expect(searchSpy).toHaveBeenCalledWith(
"Communication",
{
"part-of:missing": true,
subject: undefined,
_revinclude: "Communication:part-of",
_sort: "-sent",
},
expect.objectContaining({
cache: "no-cache",
}),
);
});

test("Creates new thread successfully", async () => {
Expand Down
5 changes: 1 addition & 4 deletions app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ export default function AppLayout() {

if (medplum.isLoading()) {
return (
<SafeAreaView
className="bg-background-50"
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
>
<SafeAreaView className="flex-1 items-center justify-center bg-background-50">
<Spinner size="large" />
</SafeAreaView>
);
Expand Down
21 changes: 12 additions & 9 deletions app/(app)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMedplum } from "@medplum/react-hooks";
import { useRouter } from "expo-router";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context";

import { CreateThreadModal } from "@/components/CreateThreadModal";
Expand All @@ -20,22 +20,25 @@ export default function Index() {
router.replace("/sign-in");
}, [medplum, router]);

// When threads are loaded, fetch their image URLs
useEffect(() => {
if (!isLoadingThreads) {
threads.forEach((thread) => {
thread.loadImageURL({ medplum });
});
}
}, [isLoadingThreads, threads, medplum]);

if (isLoadingThreads) {
return (
<SafeAreaView
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<SafeAreaView className="flex-1 items-center justify-center">
<Spinner size="large" />
</SafeAreaView>
);
}

return (
<SafeAreaView className="bg-background-50" style={{ flex: 1 }}>
<SafeAreaView className="flex-1 bg-background-50">
<ThreadListHeader onLogout={handleLogout} onCreateThread={() => setIsCreateModalOpen(true)} />
<ThreadList threads={threads} onCreateThread={() => setIsCreateModalOpen(true)} />
<CreateThreadModal
Expand Down
Loading

0 comments on commit 49586e5

Please sign in to comment.