diff --git a/app/src/api/admin.ts b/app/src/api/admin.ts index de9d72b..b38f483 100644 --- a/app/src/api/admin.ts +++ b/app/src/api/admin.ts @@ -7,10 +7,10 @@ import { apiCall } from './api'; import { isSystemStats, isUser, - isWorkspace, + isWorkspaceStats, type SystemStats, type User, - type Workspace, + type WorkspaceStats, } from '@/types/models'; const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; @@ -101,18 +101,18 @@ export const updateUser = async ( /** * Fetches all workspaces from the API - * @returns {Promise} A promise that resolves to an array of workspaces + * @returns {Promise} A promise that resolves to an array of workspaces * @throws {Error} If the API call fails or returns an invalid response * */ -export const getWorkspaces = async (): Promise => { +export const getWorkspaces = async (): Promise => { const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`); const data: unknown = await response.json(); if (!Array.isArray(data)) { throw new Error('Invalid workspaces response received from API'); } return data.map((workspace) => { - if (!isWorkspace(workspace)) { - throw new Error('Invalid workspace object received from API'); + if (!isWorkspaceStats(workspace)) { + throw new Error('Invalid workspace stats object received from API'); } return workspace; }); diff --git a/app/src/hooks/useAdminData.test.ts b/app/src/hooks/useAdminData.test.ts new file mode 100644 index 0000000..a3fd306 --- /dev/null +++ b/app/src/hooks/useAdminData.test.ts @@ -0,0 +1,608 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAdminData } from './useAdminData'; +import * as adminApi from '@/api/admin'; +import { + UserRole, + type SystemStats, + type User, + type WorkspaceStats, +} from '@/types/models'; + +// Mock dependencies +vi.mock('@/api/admin'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Import notifications for assertions +import { notifications } from '@mantine/notifications'; + +// Mock data +const mockSystemStats: SystemStats = { + totalUsers: 10, + activeUsers: 8, + totalWorkspaces: 15, + totalFiles: 150, + totalSize: 1024000, +}; + +const mockUsers: User[] = [ + { + id: 1, + email: 'admin@example.com', + displayName: 'Admin User', + role: UserRole.Admin, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }, + { + id: 2, + email: 'editor@example.com', + displayName: 'Editor User', + role: UserRole.Editor, + createdAt: '2024-01-02T00:00:00Z', + lastWorkspaceId: 2, + }, +]; + +const mockWorkspaceStats: WorkspaceStats[] = [ + { + userID: 1, + userEmail: 'admin@example.com', + workspaceID: 1, + workspaceName: 'admin-workspace', + workspaceCreatedAt: '2024-01-01T00:00:00Z', + fileCountStats: { + totalFiles: 10, + totalSize: 204800, + }, + }, + { + userID: 2, + userEmail: 'editor@example.com', + workspaceID: 2, + workspaceName: 'editor-workspace', + workspaceCreatedAt: '2024-01-02T00:00:00Z', + fileCountStats: { + totalFiles: 15, + totalSize: 307200, + }, + }, +]; + +describe('useAdminData', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('stats data type', () => { + it('initializes with empty stats and loading state', () => { + const { result } = renderHook(() => useAdminData('stats')); + + expect(result.current.data).toEqual({}); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + expect(typeof result.current.reload).toBe('function'); + }); + + it('loads system stats successfully', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockSystemStats); + expect(result.current.error).toBeNull(); + expect(mockGetSystemStats).toHaveBeenCalledTimes(1); + }); + + it('handles stats loading errors', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockRejectedValue(new Error('Failed to load stats')); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual({}); + expect(result.current.error).toBe('Failed to load stats'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load stats: Failed to load stats', + color: 'red', + }); + }); + + it('reloads stats data', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockGetSystemStats).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.reload(); + }); + + expect(mockGetSystemStats).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(mockSystemStats); + }); + }); + + describe('users data type', () => { + it('initializes with empty users array and loading state', () => { + const { result } = renderHook(() => useAdminData('users')); + + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + expect(typeof result.current.reload).toBe('function'); + }); + + it('loads users successfully', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockResolvedValue(mockUsers); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockUsers); + expect(result.current.error).toBeNull(); + expect(mockGetUsers).toHaveBeenCalledTimes(1); + }); + + it('handles users loading errors', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockRejectedValue(new Error('Failed to load users')); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual([]); + expect(result.current.error).toBe('Failed to load users'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load users: Failed to load users', + color: 'red', + }); + }); + + it('reloads users data', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockResolvedValue(mockUsers); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockGetUsers).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.reload(); + }); + + expect(mockGetUsers).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(mockUsers); + }); + + it('handles empty users array', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockResolvedValue([]); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual([]); + expect(result.current.error).toBeNull(); + }); + }); + + describe('workspaces data type', () => { + it('initializes with empty workspaces array and loading state', () => { + const { result } = renderHook(() => useAdminData('workspaces')); + + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + expect(typeof result.current.reload).toBe('function'); + }); + + it('loads workspaces successfully', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockWorkspaceStats); + expect(result.current.error).toBeNull(); + expect(mockGetWorkspaces).toHaveBeenCalledTimes(1); + }); + + it('handles workspaces loading errors', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockRejectedValue( + new Error('Failed to load workspaces') + ); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual([]); + expect(result.current.error).toBe('Failed to load workspaces'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load workspaces: Failed to load workspaces', + color: 'red', + }); + }); + + it('reloads workspaces data', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockGetWorkspaces).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.reload(); + }); + + expect(mockGetWorkspaces).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(mockWorkspaceStats); + }); + + it('handles workspaces with minimal configuration', async () => { + const minimalWorkspaceStats: WorkspaceStats[] = [ + { + userID: 3, + userEmail: 'minimal@example.com', + workspaceID: 3, + workspaceName: 'minimal-workspace', + workspaceCreatedAt: '2024-01-03T00:00:00Z', + }, + ]; + + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockResolvedValue(minimalWorkspaceStats); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(minimalWorkspaceStats); + expect(result.current.error).toBeNull(); + }); + }); + + describe('error handling', () => { + it('handles API errors with error response object', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + // Create a properly typed error object to simulate API error response + const errorWithResponse = new Error('Request failed'); + type ErrorWithResponse = Error & { + response: { + data: { + error: string; + }; + }; + }; + (errorWithResponse as ErrorWithResponse).response = { + data: { + error: 'Custom API error message', + }, + }; + mockGetSystemStats.mockRejectedValue(errorWithResponse); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Custom API error message'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load stats: Custom API error message', + color: 'red', + }); + }); + + it('handles unknown errors gracefully', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockRejectedValue('String error'); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('An unknown error occurred'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load users: An unknown error occurred', + color: 'red', + }); + }); + + it('handles network timeout errors', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockRejectedValue(new Error('Network timeout')); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Network timeout'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load workspaces: Network timeout', + color: 'red', + }); + }); + + it('clears error on successful reload', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats + .mockRejectedValueOnce(new Error('Initial error')) + .mockResolvedValueOnce(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + // Wait for initial error + await waitFor(() => { + expect(result.current.error).toBe('Initial error'); + }); + + // Reload successfully + await act(async () => { + await result.current.reload(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + expect(result.current.data).toEqual(mockSystemStats); + }); + }); + }); + + describe('loading state management', () => { + it('manages loading state correctly during initial load', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + let resolvePromise: (value: SystemStats) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockGetSystemStats.mockReturnValue(pendingPromise); + + const { result } = renderHook(() => useAdminData('stats')); + + expect(result.current.loading).toBe(true); + + await act(async () => { + resolvePromise!(mockSystemStats); + await pendingPromise; + }); + + expect(result.current.loading).toBe(false); + }); + + it('manages loading state correctly during reload', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockResolvedValue(mockUsers); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + let resolveReload: (value: User[]) => void; + const reloadPromise = new Promise((resolve) => { + resolveReload = resolve; + }); + mockGetUsers.mockReturnValueOnce(reloadPromise); + + act(() => { + void result.current.reload(); + }); + + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveReload!(mockUsers); + await reloadPromise; + }); + + expect(result.current.loading).toBe(false); + }); + + it('handles loading state during error scenarios', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + let rejectPromise: (error: Error) => void; + const errorPromise = new Promise((_, reject) => { + rejectPromise = reject; + }); + mockGetWorkspaces.mockReturnValue(errorPromise); + + const { result } = renderHook(() => useAdminData('workspaces')); + + expect(result.current.loading).toBe(true); + + await act(async () => { + rejectPromise!(new Error('Load failed')); + try { + await errorPromise; + } catch { + // Expected to fail + } + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + }); + + describe('data consistency', () => { + it('maintains data consistency across re-renders', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(mockSystemStats); + + const { result, rerender } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const initialData = result.current.data; + + rerender(); + + expect(result.current.data).toBe(initialData); + expect(result.current.data).toEqual(mockSystemStats); + }); + + it('provides stable reload function across re-renders', () => { + const { result, rerender } = renderHook(() => useAdminData('stats')); + + const initialReload = result.current.reload; + + rerender(); + + expect(result.current.reload).toBe(initialReload); + }); + + it('handles data type changes correctly', () => { + const { result: statsResult } = renderHook(() => useAdminData('stats')); + const { result: usersResult } = renderHook(() => useAdminData('users')); + const { result: workspacesResult } = renderHook(() => + useAdminData('workspaces') + ); + + // Different data types should have different initial values + expect(statsResult.current.data).toEqual({}); + expect(usersResult.current.data).toEqual([]); + expect(workspacesResult.current.data).toEqual([]); + }); + }); + + describe('function stability', () => { + it('maintains stable reload function reference', () => { + const { result, rerender } = renderHook(() => useAdminData('stats')); + + const initialReload = result.current.reload; + + rerender(); + + expect(result.current.reload).toBe(initialReload); + }); + }); + + describe('concurrent operations', () => { + it('handles multiple concurrent reloads', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Trigger multiple reloads + await act(async () => { + await Promise.all([ + result.current.reload(), + result.current.reload(), + result.current.reload(), + ]); + }); + + expect(mockGetSystemStats).toHaveBeenCalledTimes(4); // 1 initial + 3 reloads + expect(result.current.data).toEqual(mockSystemStats); + expect(result.current.loading).toBe(false); + }); + }); + + describe('edge cases', () => { + it('handles invalid data type gracefully', async () => { + // This would normally be caught by TypeScript, but test runtime behavior + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockRejectedValue(new Error('Invalid data type')); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Invalid data type'); + }); + + it('handles partial data responses', async () => { + const partialStats = { + totalUsers: 5, + activeUsers: 3, + // Missing other required fields + }; + + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(partialStats as SystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(partialStats); + expect(result.current.error).toBeNull(); + }); + }); +}); diff --git a/app/src/types/models.ts b/app/src/types/models.ts index 8e2f905..89a8967 100644 --- a/app/src/types/models.ts +++ b/app/src/types/models.ts @@ -208,12 +208,46 @@ export interface WorkspaceStats { fileCountStats?: FileCountStats; } +// isWorkspaceStats checks if the given object is a valid WorkspaceStats object +export function isWorkspaceStats(obj: unknown): obj is WorkspaceStats { + return ( + typeof obj === 'object' && + obj !== null && + 'userID' in obj && + typeof (obj as WorkspaceStats).userID === 'number' && + 'userEmail' in obj && + typeof (obj as WorkspaceStats).userEmail === 'string' && + 'workspaceID' in obj && + typeof (obj as WorkspaceStats).workspaceID === 'number' && + 'workspaceName' in obj && + typeof (obj as WorkspaceStats).workspaceName === 'string' && + 'workspaceCreatedAt' in obj && + typeof (obj as WorkspaceStats).workspaceCreatedAt === 'string' && + (!('fileCountStats' in obj) || + (obj as WorkspaceStats).fileCountStats === undefined || + (obj as WorkspaceStats).fileCountStats === null || + isFileCountStats((obj as WorkspaceStats).fileCountStats)) + ); +} + // Define FileCountStats based on the Go struct definition of storage.FileCountStats export interface FileCountStats { totalFiles: number; totalSize: number; } +// isFileCountStats checks if the given object is a valid FileCountStats object +export function isFileCountStats(obj: unknown): obj is FileCountStats { + return ( + typeof obj === 'object' && + obj !== null && + 'totalFiles' in obj && + typeof (obj as FileCountStats).totalFiles === 'number' && + 'totalSize' in obj && + typeof (obj as FileCountStats).totalSize === 'number' + ); +} + export interface UserStats { totalUsers: number; totalWorkspaces: number;