From f22f87250c23bcf91bf72e96ba752f14f5647b75 Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Thu, 6 Nov 2025 12:49:46 +0100 Subject: [PATCH] Refactor and centralize user and pagination interfaces in `useAdmin` hook - Unified `User` and `PaginationMeta` type definitions into `useAdmin` to improve maintainability and consistency. - Updated affected components (`UserManagementContent`, `UserListTable`, `UserFormDialog`, `UserActionMenu`) to reference the centralized types. - Enhanced test coverage for user-related hooks to include create, update, delete, activate, deactivate, and bulk actions. --- .../components/admin/users/UserActionMenu.tsx | 10 +- .../components/admin/users/UserFormDialog.tsx | 10 +- .../components/admin/users/UserListTable.tsx | 20 +- .../admin/users/UserManagementContent.tsx | 10 +- frontend/src/lib/api/hooks/useAdmin.tsx | 37 ++- .../tests/lib/api/hooks/useAdmin.test.tsx | 278 +++++++++++++++++- 6 files changed, 316 insertions(+), 49 deletions(-) diff --git a/frontend/src/components/admin/users/UserActionMenu.tsx b/frontend/src/components/admin/users/UserActionMenu.tsx index 3a86f20..c9ebf78 100644 --- a/frontend/src/components/admin/users/UserActionMenu.tsx +++ b/frontend/src/components/admin/users/UserActionMenu.tsx @@ -30,17 +30,9 @@ import { useActivateUser, useDeactivateUser, useDeleteUser, + type User, } from '@/lib/api/hooks/useAdmin'; -interface User { - id: string; - email: string; - first_name: string; - last_name: string | null; - is_active: boolean; - is_superuser: boolean; -} - interface UserActionMenuProps { user: User; isCurrentUser: boolean; diff --git a/frontend/src/components/admin/users/UserFormDialog.tsx b/frontend/src/components/admin/users/UserFormDialog.tsx index 0e0b29d..2dd51a6 100644 --- a/frontend/src/components/admin/users/UserFormDialog.tsx +++ b/frontend/src/components/admin/users/UserFormDialog.tsx @@ -26,6 +26,7 @@ import { toast } from 'sonner'; import { useCreateUser, useUpdateUser, + type User, } from '@/lib/api/hooks/useAdmin'; // ============================================================================ @@ -58,15 +59,6 @@ type UserFormData = z.infer; // Component // ============================================================================ -interface User { - id: string; - email: string; - first_name: string; - last_name: string | null; - is_active: boolean; - is_superuser: boolean; -} - interface UserFormDialogProps { open: boolean; onOpenChange: (open: boolean) => void; diff --git a/frontend/src/components/admin/users/UserListTable.tsx b/frontend/src/components/admin/users/UserListTable.tsx index 0bd892d..e1098de 100644 --- a/frontend/src/components/admin/users/UserListTable.tsx +++ b/frontend/src/components/admin/users/UserListTable.tsx @@ -29,25 +29,7 @@ import { SelectValue, } from '@/components/ui/select'; import { UserActionMenu } from './UserActionMenu'; - -interface User { - id: string; - email: string; - first_name: string; - last_name: string | null; - is_active: boolean; - is_superuser: boolean; - created_at: string; -} - -interface PaginationMeta { - total: number; - page: number; - page_size: number; - total_pages: number; - has_next: boolean; - has_prev: boolean; -} +import type { User, PaginationMeta } from '@/lib/api/hooks/useAdmin'; interface UserListTableProps { users: User[]; diff --git a/frontend/src/components/admin/users/UserManagementContent.tsx b/frontend/src/components/admin/users/UserManagementContent.tsx index f25e2f6..3d8fa74 100644 --- a/frontend/src/components/admin/users/UserManagementContent.tsx +++ b/frontend/src/components/admin/users/UserManagementContent.tsx @@ -10,7 +10,7 @@ import { useSearchParams, useRouter } from 'next/navigation'; import { Plus } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useAuth } from '@/lib/auth/AuthContext'; -import { useAdminUsers } from '@/lib/api/hooks/useAdmin'; +import { useAdminUsers, type User, type PaginationMeta } from '@/lib/api/hooks/useAdmin'; import { UserListTable } from './UserListTable'; import { UserFormDialog } from './UserFormDialog'; import { BulkActionToolbar } from './BulkActionToolbar'; @@ -30,13 +30,13 @@ export function UserManagementContent() { const [selectedUsers, setSelectedUsers] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create'); - const [editingUser, setEditingUser] = useState(null); + const [editingUser, setEditingUser] = useState(null); // Fetch users with query params const { data, isLoading } = useAdminUsers(page, 20); - const users = data?.data || []; - const pagination = data?.pagination || { + const users: User[] = data?.data || []; + const pagination: PaginationMeta = data?.pagination || { total: 0, page: 1, page_size: 20, @@ -107,7 +107,7 @@ export function UserManagementContent() { setDialogOpen(true); }; - const handleEditUser = (user: any) => { + const handleEditUser = (user: User) => { setDialogMode('edit'); setEditingUser(user); setDialogOpen(true); diff --git a/frontend/src/lib/api/hooks/useAdmin.tsx b/frontend/src/lib/api/hooks/useAdmin.tsx index 4fa74f0..ef38381 100644 --- a/frontend/src/lib/api/hooks/useAdmin.tsx +++ b/frontend/src/lib/api/hooks/useAdmin.tsx @@ -123,6 +123,39 @@ export function useAdminStats() { }); } +/** + * Pagination metadata structure + */ +export interface PaginationMeta { + total: number; + page: number; + page_size: number; + total_pages: number; + has_next: boolean; + has_prev: boolean; +} + +/** + * User interface matching backend UserResponse + */ +export interface User { + id: string; + email: string; + first_name: string; + last_name: string | null; + is_active: boolean; + is_superuser: boolean; + created_at: string; +} + +/** + * Paginated user list response + */ +export interface PaginatedUserResponse { + data: User[]; + pagination: PaginationMeta; +} + /** * Hook to fetch paginated list of all users (for admin) * @@ -135,7 +168,7 @@ export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) { return useQuery({ queryKey: ['admin', 'users', page, limit], - queryFn: async () => { + queryFn: async (): Promise => { const response = await adminListUsers({ query: { page, limit }, throwOnError: false, @@ -146,7 +179,7 @@ export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) { } // Type assertion: if no error, response has data - return (response as { data: unknown }).data; + return (response as { data: PaginatedUserResponse }).data; }, // Only fetch if user is a superuser (frontend guard) enabled: user?.is_superuser === true, diff --git a/frontend/tests/lib/api/hooks/useAdmin.test.tsx b/frontend/tests/lib/api/hooks/useAdmin.test.tsx index 36558e5..be2a3b2 100644 --- a/frontend/tests/lib/api/hooks/useAdmin.test.tsx +++ b/frontend/tests/lib/api/hooks/useAdmin.test.tsx @@ -3,10 +3,29 @@ * Verifies admin statistics and list fetching functionality */ -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor, act } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useAdminStats, useAdminUsers, useAdminOrganizations } from '@/lib/api/hooks/useAdmin'; -import { adminListUsers, adminListOrganizations } from '@/lib/api/client'; +import { + useAdminStats, + useAdminUsers, + useAdminOrganizations, + useCreateUser, + useUpdateUser, + useDeleteUser, + useActivateUser, + useDeactivateUser, + useBulkUserAction, +} from '@/lib/api/hooks/useAdmin'; +import { + adminListUsers, + adminListOrganizations, + adminCreateUser, + adminUpdateUser, + adminDeleteUser, + adminActivateUser, + adminDeactivateUser, + adminBulkUserAction, +} from '@/lib/api/client'; import { useAuth } from '@/lib/auth/AuthContext'; // Mock dependencies @@ -79,12 +98,12 @@ describe('useAdmin hooks', () => { }); expect(mockAdminListUsers).toHaveBeenCalledWith({ - query: { page: 1, limit: 10000 }, + query: { page: 1, limit: 100 }, throwOnError: false, }); expect(mockAdminListOrganizations).toHaveBeenCalledWith({ - query: { page: 1, limit: 10000 }, + query: { page: 1, limit: 100 }, throwOnError: false, }); }); @@ -327,4 +346,253 @@ describe('useAdmin hooks', () => { expect(result.current.error).toBeDefined(); }); }); + + describe('useCreateUser', () => { + it('creates a user successfully', async () => { + const mockCreateUser = adminCreateUser as jest.MockedFunction; + mockCreateUser.mockResolvedValue({ + data: { id: '1', email: 'newuser@example.com', first_name: 'New', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' }, + } as any); + + const { result } = renderHook(() => useCreateUser(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + email: 'newuser@example.com', + first_name: 'New', + last_name: 'User', + password: 'Password123', + is_active: true, + is_superuser: false, + }); + }); + + expect(mockCreateUser).toHaveBeenCalledWith({ + body: { + email: 'newuser@example.com', + first_name: 'New', + last_name: 'User', + password: 'Password123', + is_active: true, + is_superuser: false, + }, + throwOnError: false, + }); + }); + + it('handles create error', async () => { + const mockCreateUser = adminCreateUser as jest.MockedFunction; + mockCreateUser.mockResolvedValue({ error: 'Create failed' } as any); + + const { result } = renderHook(() => useCreateUser(), { wrapper }); + + await expect( + result.current.mutateAsync({ + email: 'test@example.com', + first_name: 'Test', + password: 'Password123', + is_active: true, + is_superuser: false, + }) + ).rejects.toThrow('Failed to create user'); + }); + }); + + describe('useUpdateUser', () => { + it('updates a user successfully', async () => { + const mockUpdateUser = adminUpdateUser as jest.MockedFunction; + mockUpdateUser.mockResolvedValue({ + data: { id: '1', email: 'updated@example.com', first_name: 'Updated', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' }, + } as any); + + const { result } = renderHook(() => useUpdateUser(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + userId: '1', + userData: { + email: 'updated@example.com', + first_name: 'Updated', + }, + }); + }); + + expect(mockUpdateUser).toHaveBeenCalledWith({ + path: { user_id: '1' }, + body: { + email: 'updated@example.com', + first_name: 'Updated', + }, + throwOnError: false, + }); + }); + + it('handles update error', async () => { + const mockUpdateUser = adminUpdateUser as jest.MockedFunction; + mockUpdateUser.mockResolvedValue({ error: 'Update failed' } as any); + + const { result } = renderHook(() => useUpdateUser(), { wrapper }); + + await expect( + result.current.mutateAsync({ + userId: '1', + userData: { email: 'test@example.com' }, + }) + ).rejects.toThrow('Failed to update user'); + }); + }); + + describe('useDeleteUser', () => { + it('deletes a user successfully', async () => { + const mockDeleteUser = adminDeleteUser as jest.MockedFunction; + mockDeleteUser.mockResolvedValue({ data: { success: true } } as any); + + const { result } = renderHook(() => useDeleteUser(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync('1'); + }); + + expect(mockDeleteUser).toHaveBeenCalledWith({ + path: { user_id: '1' }, + throwOnError: false, + }); + }); + + it('handles delete error', async () => { + const mockDeleteUser = adminDeleteUser as jest.MockedFunction; + mockDeleteUser.mockResolvedValue({ error: 'Delete failed' } as any); + + const { result } = renderHook(() => useDeleteUser(), { wrapper }); + + await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to delete user'); + }); + }); + + describe('useActivateUser', () => { + it('activates a user successfully', async () => { + const mockActivateUser = adminActivateUser as jest.MockedFunction; + mockActivateUser.mockResolvedValue({ data: { success: true } } as any); + + const { result } = renderHook(() => useActivateUser(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync('1'); + }); + + expect(mockActivateUser).toHaveBeenCalledWith({ + path: { user_id: '1' }, + throwOnError: false, + }); + }); + + it('handles activate error', async () => { + const mockActivateUser = adminActivateUser as jest.MockedFunction; + mockActivateUser.mockResolvedValue({ error: 'Activate failed' } as any); + + const { result } = renderHook(() => useActivateUser(), { wrapper }); + + await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to activate user'); + }); + }); + + describe('useDeactivateUser', () => { + it('deactivates a user successfully', async () => { + const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction; + mockDeactivateUser.mockResolvedValue({ data: { success: true } } as any); + + const { result } = renderHook(() => useDeactivateUser(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync('1'); + }); + + expect(mockDeactivateUser).toHaveBeenCalledWith({ + path: { user_id: '1' }, + throwOnError: false, + }); + }); + + it('handles deactivate error', async () => { + const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction; + mockDeactivateUser.mockResolvedValue({ error: 'Deactivate failed' } as any); + + const { result } = renderHook(() => useDeactivateUser(), { wrapper }); + + await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to deactivate user'); + }); + }); + + describe('useBulkUserAction', () => { + it('performs bulk activate successfully', async () => { + const mockBulkAction = adminBulkUserAction as jest.MockedFunction; + mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 2 } } as any); + + const { result } = renderHook(() => useBulkUserAction(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + action: 'activate', + userIds: ['1', '2'], + }); + }); + + expect(mockBulkAction).toHaveBeenCalledWith({ + body: { action: 'activate', user_ids: ['1', '2'] }, + throwOnError: false, + }); + }); + + it('performs bulk deactivate successfully', async () => { + const mockBulkAction = adminBulkUserAction as jest.MockedFunction; + mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 3 } } as any); + + const { result } = renderHook(() => useBulkUserAction(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + action: 'deactivate', + userIds: ['1', '2', '3'], + }); + }); + + expect(mockBulkAction).toHaveBeenCalledWith({ + body: { action: 'deactivate', user_ids: ['1', '2', '3'] }, + throwOnError: false, + }); + }); + + it('performs bulk delete successfully', async () => { + const mockBulkAction = adminBulkUserAction as jest.MockedFunction; + mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 1 } } as any); + + const { result } = renderHook(() => useBulkUserAction(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + action: 'delete', + userIds: ['1'], + }); + }); + + expect(mockBulkAction).toHaveBeenCalledWith({ + body: { action: 'delete', user_ids: ['1'] }, + throwOnError: false, + }); + }); + + it('handles bulk action error', async () => { + const mockBulkAction = adminBulkUserAction as jest.MockedFunction; + mockBulkAction.mockResolvedValue({ error: 'Bulk action failed' } as any); + + const { result } = renderHook(() => useBulkUserAction(), { wrapper }); + + await expect( + result.current.mutateAsync({ + action: 'activate', + userIds: ['1', '2'], + }) + ).rejects.toThrow('Failed to perform bulk action'); + }); + }); });