Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions app/src/__tests__/services/messageService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { renderHook, act } from '@testing-library/react-native';

jest.mock('@/services/loggerService', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));

import { DEFAULT_OFFSET, MESSAGE_PAGE_SIZE } from '@/constants/api';
import { useMessageApi } from '@/services/messageService';
import type { PaginatedResponse } from '@/types/api';
import type { Message } from '@/types/chat';

const mockGet = jest.fn();

jest.mock('@/hooks/useApi', () => ({
useApi: () => ({
get: mockGet,
}),
}));

const chatId = 'CHAT-123';
const setup = () => renderHook(() => useMessageApi(chatId)).result.current;

const mockMessage: Message = {
id: 'MSG-1',
revision: 1,
chat: { id: chatId, type: 'Direct', revision: 1 },
sender: {
id: 'P-1',
identity: { id: 'USR-1', name: 'Test User', revision: 1 },
unreadMessageCount: 0,
chat: { id: chatId, type: 'Direct', revision: 1 },
muted: false,
status: 'Active',
lastReadMessage: { id: 'MSG-0', content: '', audit: undefined, isDeleted: false },
},
identity: { id: 'USR-1', name: 'Test User', revision: 1 },
content: 'Test message content',
visibility: 'Public',
isDeleted: false,
links: [],
audit: {
created: { at: '2026-03-17T10:00:00Z', by: 'USR-1' },
},
};

const expectedUrlBase =
`/v1/helpdesk/chats/${chatId}/messages` +
`?select=audit,audit.created,links,sender,sender.identity,identity,content,visibility,isDeleted` +
`&order=-audit.created.at`;

describe('useMessageApi', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('getMessages', () => {
it('calls api.get with correct endpoint and default offset/limit', async () => {
const api = setup();
const mockResponse: PaginatedResponse<Message> = {
$meta: {
pagination: {
offset: DEFAULT_OFFSET,
limit: MESSAGE_PAGE_SIZE,
total: 1,
},
},
data: [mockMessage],
};

mockGet.mockResolvedValueOnce(mockResponse);

let result;
await act(async () => {
result = await api.getMessages();
});

const expectedUrl =
expectedUrlBase + `&offset=${DEFAULT_OFFSET}` + `&limit=${MESSAGE_PAGE_SIZE}`;

expect(mockGet).toHaveBeenCalledWith(expectedUrl);
expect(result).toEqual(mockResponse);
});

it('calls api.get with custom offset and limit', async () => {
const api = setup();
const mockResponse: PaginatedResponse<Message> = {
$meta: {
pagination: {
offset: 20,
limit: 10,
total: 50,
},
},
data: [mockMessage],
};

mockGet.mockResolvedValueOnce(mockResponse);

let result;
await act(async () => {
result = await api.getMessages(20, 10);
});

const expectedUrl = expectedUrlBase + `&offset=20` + `&limit=10`;

expect(mockGet).toHaveBeenCalledWith(expectedUrl);
expect(result).toEqual(mockResponse);
});

it('returns response with empty data array when no messages found', async () => {
const api = setup();
const mockResponse: PaginatedResponse<Message> = {
$meta: {
pagination: {
offset: 0,
limit: MESSAGE_PAGE_SIZE,
total: 0,
},
},
data: [],
};

mockGet.mockResolvedValueOnce(mockResponse);

let result: PaginatedResponse<Message> | undefined;
await act(async () => {
result = await api.getMessages();
});

expect(result).toEqual(mockResponse);
expect(result!.data).toEqual([]);
});

it('returns response with multiple messages', async () => {
const api = setup();
const mockMessage2: Message = {
...mockMessage,
id: 'MSG-2',
content: 'Another message',
};
const mockResponse: PaginatedResponse<Message> = {
$meta: {
pagination: {
offset: 0,
limit: MESSAGE_PAGE_SIZE,
total: 2,
},
},
data: [mockMessage, mockMessage2],
};

mockGet.mockResolvedValueOnce(mockResponse);

let result: PaginatedResponse<Message> | undefined;
await act(async () => {
result = await api.getMessages();
});

expect(result!.data).toHaveLength(2);
expect(result!.data[0].id).toBe('MSG-1');
expect(result!.data[1].id).toBe('MSG-2');
});

it('handles API errors', async () => {
const api = setup();
const mockError = new Error('API Error');

mockGet.mockRejectedValueOnce(mockError);

await expect(async () => {
await act(async () => {
await api.getMessages();
});
}).rejects.toThrow('API Error');
});

it('constructs endpoint with chatId in the path', async () => {
const api = setup();
const mockResponse: PaginatedResponse<Message> = {
$meta: {
pagination: {
offset: 0,
limit: MESSAGE_PAGE_SIZE,
total: 1,
},
},
data: [mockMessage],
};

mockGet.mockResolvedValueOnce(mockResponse);

await act(async () => {
await api.getMessages();
});

const calledUrl = mockGet.mock.calls[0][0];
expect(calledUrl).toContain(`/v1/helpdesk/chats/${chatId}/messages`);
});

it('includes required select parameters in endpoint', async () => {
const api = setup();
const mockResponse: PaginatedResponse<Message> = {
$meta: {
pagination: {
offset: 0,
limit: MESSAGE_PAGE_SIZE,
total: 1,
},
},
data: [mockMessage],
};

mockGet.mockResolvedValueOnce(mockResponse);

await act(async () => {
await api.getMessages();
});

const calledUrl = mockGet.mock.calls[0][0];
expect(calledUrl).toContain('select=audit');
expect(calledUrl).toContain('sender');
expect(calledUrl).toContain('identity');
});

it('orders messages by audit.created.at descending', async () => {
const api = setup();
const mockResponse: PaginatedResponse<Message> = {
$meta: {
pagination: {
offset: 0,
limit: MESSAGE_PAGE_SIZE,
total: 1,
},
},
data: [mockMessage],
};

mockGet.mockResolvedValueOnce(mockResponse);

await act(async () => {
await api.getMessages();
});

const calledUrl = mockGet.mock.calls[0][0];
expect(calledUrl).toContain('order=-audit.created.at');
});
});
});
1 change: 1 addition & 0 deletions app/src/constants/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const DEFAULT_PAGE_SIZE = 50;
export const MESSAGE_PAGE_SIZE = 20;
export const DEFAULT_OFFSET = 0;

export const DEFAULT_SPOTLIGHT_LIMIT = 100;
Expand Down
61 changes: 61 additions & 0 deletions app/src/context/MessagesContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createContext, ReactNode, useContext, useMemo } from 'react';

import { useMessagesData } from '@/hooks/queries/useMessagesData';
import type { Message } from '@/types/chat';

interface MessagesContextValue {
messages: Message[];
messagesLoading: boolean;
messagesFetchingNext: boolean;
hasMoreMessages: boolean;
messagesError: boolean;
isUnauthorised: boolean;
fetchMessages: () => void;
chatId: string | undefined;
}

interface MessagesProviderProps {
chatId: string | undefined;
children: ReactNode;
}

const MessagesContext = createContext<MessagesContextValue | undefined>(undefined);

export const MessagesProvider = ({ chatId, children }: MessagesProviderProps) => {
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
isError,
isUnauthorised,
fetchNextPage,
} = useMessagesData(chatId);

const messages = useMemo(() => data?.pages.flatMap((page) => page.data) ?? [], [data]);

return (
<MessagesContext.Provider
value={{
messages,
messagesLoading: isLoading,
messagesFetchingNext: isFetchingNextPage,
hasMoreMessages: !!hasNextPage,
messagesError: isError,
isUnauthorised,
fetchMessages: fetchNextPage,
chatId,
}}
>
{children}
</MessagesContext.Provider>
);
};

export const useMessages = () => {
const context = useContext(MessagesContext);
if (!context) {
throw new Error('useMessages must be used inside MessagesProvider');
}
return context;
};
20 changes: 20 additions & 0 deletions app/src/hooks/queries/useMessagesData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { MESSAGE_PAGE_SIZE } from '@/constants/api';
import { usePaginatedQuery } from '@/hooks/queries/usePaginatedQuery';
import { useMessageApi } from '@/services/messageService';
import type { Message } from '@/types/chat';

export const useMessagesData = (chatId: string | undefined) => {
const messageApi = useMessageApi(chatId ?? '');

return usePaginatedQuery<Message>({
queryKey: ['messages', chatId],
queryFn: (offset, limit) => {
if (!chatId) {
throw new Error('chatId is required for fetching messages');
}
return messageApi.getMessages(offset, limit);
},
enabled: !!chatId,
pageSize: MESSAGE_PAGE_SIZE,
});
};
15 changes: 13 additions & 2 deletions app/src/hooks/queries/usePaginatedQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ interface UsePaginatedQueryParams<T> {
queryKey: readonly unknown[];
queryFn: (offset: number, limit: number) => Promise<PaginatedResponse<T>>;
enabled?: boolean;
pageSize?: number;
}

export function usePaginatedQuery<T>({
queryKey,
queryFn,
enabled = true,
pageSize = DEFAULT_PAGE_SIZE,
}: UsePaginatedQueryParams<T>) {
const query = useInfiniteQuery<
PaginatedResponse<T>,
Expand All @@ -23,12 +25,21 @@ export function usePaginatedQuery<T>({
number
>({
queryKey,
queryFn: ({ pageParam = 0 }) => queryFn(pageParam, DEFAULT_PAGE_SIZE),
queryFn: ({ pageParam = 0 }) => queryFn(pageParam, pageSize),

getNextPageParam: (lastPage) => {
const { offset, limit, total } = lastPage.$meta.pagination;
const nextOffset = offset + limit;
return nextOffset < total ? nextOffset : undefined;

// If total is provided (most endpoints), use it for accurate pagination
if (total !== undefined) {
return nextOffset < total ? nextOffset : undefined;
}

// Fallback for endpoints without total (e.g., messages API):
// If we received fewer items than requested, we've reached the end
const receivedCount = lastPage.data?.length ?? 0;
return receivedCount === limit ? nextOffset : undefined;
},

initialPageParam: 0,
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"emptyStateTitle": "No chats",
"emptyStateDescription": "No chats found."
},
"messagesScreen": {
"emptyStateTitle": "No messages",
"emptyStateDescription": "No messages in this conversation yet."
},
"chat": {
"messageInputPlaceholder": "Type a message"
}
Expand Down
Loading
Loading