diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetChatHistory.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetChatHistory.test.tsx new file mode 100644 index 0000000000..4f32aeb60d --- /dev/null +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetChatHistory.test.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useGetChatHistory } from "@api/useGetChatHistory"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +const mockLogger = { + debug: vi.fn(), + error: vi.fn(), +}; +vi.mock("@/hooks/useLogger", () => ({ + useLogger: () => mockLogger, +})); + +vi.mock("@/constants/apiConstants", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + API_MOCK_DELAY: 0, + }; +}); + +const mockGetIdToken = vi.fn().mockResolvedValue("mock-token"); +vi.mock("@asgardeo/react", () => ({ + useAsgardeo: () => ({ + getIdToken: mockGetIdToken, + isSignedIn: true, + isLoading: false, + }), +})); + +let mockIsMockEnabled = true; +vi.mock("@/providers/MockConfigProvider", () => ({ + useMockConfig: () => ({ + isMockEnabled: mockIsMockEnabled, + }), +})); + +describe("useGetChatHistory", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + mockLogger.debug.mockClear(); + mockLogger.error.mockClear(); + mockIsMockEnabled = true; + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it("should return loading state initially", async () => { + const { result } = renderHook( + () => useGetChatHistory("project-1"), + { wrapper }, + ); + + expect(result.current.isLoading).toBe(true); + }); + + it("should return mock data when mock is enabled", async () => { + mockIsMockEnabled = true; + const { result } = renderHook( + () => useGetChatHistory("project-1"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.chatHistory).toBeDefined(); + expect(Array.isArray(result.current.data?.chatHistory)).toBe(true); + expect(result.current.data?.chatHistory.length).toBeGreaterThan(0); + expect(result.current.data?.chatHistory[0]).toMatchObject({ + chatId: expect.any(String), + title: expect.any(String), + startedTime: expect.any(String), + messages: expect.any(Number), + kbArticles: expect.any(Number), + status: expect.any(String), + }); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining( + "Fetching chat history for project ID: project-1, mock: true", + ), + ); + }); + + it("should have correct query options", () => { + renderHook(() => useGetChatHistory("project-1"), { wrapper }); + + const query = queryClient.getQueryCache().findAll({ + queryKey: ["chat-history", "project-1", true], + })[0]; + + expect((query?.options as { staleTime?: number }).staleTime).toBe( + 5 * 60 * 1000, + ); + }); + + it("should fetch real data when mock is disabled", async () => { + mockIsMockEnabled = false; + const mockResponse = { + chatHistory: [ + { + chatId: "1", + title: "Test chat", + startedTime: "1 hour ago", + messages: 3, + kbArticles: 1, + status: "Resolved", + }, + ], + }; + + const originalConfig = window.config; + window.config = { + CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.test", + } as typeof window.config; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + status: 200, + } as Response), + ); + + const { result } = renderHook( + () => useGetChatHistory("project-1"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data).toEqual(mockResponse); + expect(mockGetIdToken).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining( + "Fetching chat history for project ID: project-1, mock: false", + ), + ); + + window.config = originalConfig; + }); + + it("should handle API error when mock is disabled", async () => { + mockIsMockEnabled = false; + + const originalConfig = window.config; + window.config = { + CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.test", + } as typeof window.config; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + statusText: "Internal Server Error", + status: 500, + } as Response), + ); + + const { result } = renderHook( + () => useGetChatHistory("project-1"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toContain( + "Error fetching chat history: Internal Server Error", + ); + expect(mockLogger.error).toHaveBeenCalled(); + + window.config = originalConfig; + }); + + it("should not fetch if projectId is missing", () => { + const { result } = renderHook(() => useGetChatHistory(""), { + wrapper, + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.fetchStatus).toBe("idle"); + }); +}); diff --git a/apps/customer-portal/webapp/src/api/useGetChatHistory.ts b/apps/customer-portal/webapp/src/api/useGetChatHistory.ts new file mode 100644 index 0000000000..9b4eba9024 --- /dev/null +++ b/apps/customer-portal/webapp/src/api/useGetChatHistory.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useAsgardeo } from "@asgardeo/react"; +import { getMockChatHistory } from "@models/mockFunctions"; +import { useMockConfig } from "@providers/MockConfigProvider"; +import { useLogger } from "@hooks/useLogger"; +import { ApiQueryKeys, API_MOCK_DELAY } from "@constants/apiConstants"; +import { addApiHeaders } from "@utils/apiUtils"; +import type { ChatHistoryResponse } from "@models/responses"; + +/** + * Fetches chat history for a project. + * + * @param {string} projectId - The project ID. + * @returns {UseQueryResult} The query result. + */ +export function useGetChatHistory( + projectId: string, +): UseQueryResult { + const logger = useLogger(); + const { getIdToken, isSignedIn, isLoading: isAuthLoading } = useAsgardeo(); + const { isMockEnabled } = useMockConfig(); + + return useQuery({ + queryKey: [ApiQueryKeys.CHAT_HISTORY, projectId, isMockEnabled], + queryFn: async (): Promise => { + logger.debug( + `Fetching chat history for project ID: ${projectId}, mock: ${isMockEnabled}`, + ); + + if (isMockEnabled) { + await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY)); + const data = getMockChatHistory(); + logger.debug( + `Chat history fetched successfully for project ID: ${projectId} (mock)`, + data, + ); + return data; + } + + try { + const idToken = await getIdToken(); + const baseUrl = window.config?.CUSTOMER_PORTAL_BACKEND_BASE_URL; + + if (!baseUrl) { + throw new Error("CUSTOMER_PORTAL_BACKEND_BASE_URL is not configured"); + } + + const requestUrl = `${baseUrl}/projects/${projectId}/chat-history`; + + const response = await fetch(requestUrl, { + method: "GET", + headers: addApiHeaders(idToken), + }); + + logger.debug( + `[useGetChatHistory] Response status: ${response.status}`, + ); + + if (!response.ok) { + throw new Error( + `Error fetching chat history: ${response.statusText}`, + ); + } + + const data: ChatHistoryResponse = await response.json(); + logger.debug("[useGetChatHistory] Data received:", data); + return data; + } catch (error) { + logger.error("[useGetChatHistory] Error:", error); + throw error; + } + }, + enabled: !!projectId && (isMockEnabled || (isSignedIn && !isAuthLoading)), + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/customer-portal/webapp/src/constants/apiConstants.ts b/apps/customer-portal/webapp/src/constants/apiConstants.ts index 519a1a51f6..7c9a608884 100644 --- a/apps/customer-portal/webapp/src/constants/apiConstants.ts +++ b/apps/customer-portal/webapp/src/constants/apiConstants.ts @@ -27,4 +27,5 @@ export const ApiQueryKeys = { DASHBOARD_STATS: "dashboard-stats", PROJECT_STATS: "project-stats", PROJECT_CASES: "project-cases", + CHAT_HISTORY: "chat-history", } as const; diff --git a/apps/customer-portal/webapp/src/models/mockData.ts b/apps/customer-portal/webapp/src/models/mockData.ts index b9f804925c..1e8cb4c424 100644 --- a/apps/customer-portal/webapp/src/models/mockData.ts +++ b/apps/customer-portal/webapp/src/models/mockData.ts @@ -19,6 +19,7 @@ import type { ProjectListItem, ProjectDetails, UserDetails, + ChatHistoryResponse, } from "@models/responses"; import { PROJECT_TYPE, @@ -153,6 +154,52 @@ export const mockOpenCasesOptions: number[] = [0, 5, 12, 24, 48, 72]; // Mock active chats counts for projects. export const mockActiveChatsOptions: number[] = [0, 2, 5, 10, 15, 20]; +// Mock chat history returned when isMockEnabled (useGetChatHistory). +export const mockChatHistory: ChatHistoryResponse = { + chatHistory: [ + { + chatId: "1628192673", + title: "How do I configure custom claims in JWT tokens?", + startedTime: "2 hours ago", + messages: 8, + kbArticles: 3, + status: "Resolved", + }, + { + chatId: "1628192674", + title: "Getting error 401 when calling the API endpoint...", + startedTime: "1 day ago", + messages: 5, + kbArticles: 2, + status: "Still Open", + }, + { + chatId: "1628192675", + title: "Need help understanding rate limiting configuration", + startedTime: "2 days ago", + messages: 12, + kbArticles: 5, + status: "Resolved", + }, + { + chatId: "1628192676", + title: "Can I integrate with Azure AD for authentication?", + startedTime: "3 days ago", + messages: 6, + kbArticles: 4, + status: "Resolved", + }, + { + chatId: "1628192677", + title: "Deployment best practices for high availability setup", + startedTime: "5 days ago", + messages: 15, + kbArticles: 7, + status: "Abandoned", + }, + ], +}; + export const mockUserDetails: UserDetails = { id: "c2d1c8961b2dfe50a002c9d3604bcba0", email: "John@example.com", diff --git a/apps/customer-portal/webapp/src/models/mockFunctions.ts b/apps/customer-portal/webapp/src/models/mockFunctions.ts index 7c493a2c36..21852a69ea 100644 --- a/apps/customer-portal/webapp/src/models/mockFunctions.ts +++ b/apps/customer-portal/webapp/src/models/mockFunctions.ts @@ -17,6 +17,7 @@ import { mockActiveChatsOptions, mockCaseCreationMetadata, + mockChatHistory, mockOpenCasesOptions, mockStatusOptions, } from "@models/mockData"; @@ -27,6 +28,7 @@ import type { ProjectSupportStats, DashboardMockStats, ProjectStatsResponse, + ChatHistoryResponse, } from "@models/responses"; /** @@ -233,6 +235,15 @@ export const getMockDashboardStats = (): DashboardMockStats => { }; }; +/** + * Returns mock chat history for a project. + * + * @returns {ChatHistoryResponse} Mock chat history list. + */ +export const getMockChatHistory = (): ChatHistoryResponse => { + return mockChatHistory; +}; + /** * Returns mock project statistics. * diff --git a/apps/customer-portal/webapp/src/models/responses.ts b/apps/customer-portal/webapp/src/models/responses.ts index c14d737c18..c6fb84651b 100644 --- a/apps/customer-portal/webapp/src/models/responses.ts +++ b/apps/customer-portal/webapp/src/models/responses.ts @@ -196,3 +196,18 @@ export interface CaseMetadataResponse { severities: MetadataItem[]; caseTypes: MetadataItem[]; } + +// Chat history list item (support chat session summary). +export interface ChatHistoryItem { + chatId: string; + title: string; + startedTime: string; + messages: number; + kbArticles: number; + status: string; +} + +// Response for project chat history list. +export interface ChatHistoryResponse { + chatHistory: ChatHistoryItem[]; +}