Files
lemma/app/src/hooks/useUserAdmin.test.ts

628 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useUserAdmin } from './useUserAdmin';
import * as adminApi from '@/api/admin';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
import { UserRole, Theme, type User } from '@/types/models';
// Mock dependencies
vi.mock('@/api/admin');
vi.mock('@mantine/notifications', () => ({
notifications: {
show: vi.fn(),
},
}));
// Mock useAdminData hook
const mockAdminData = {
data: [] as User[],
loading: false,
error: null as string | null,
reload: vi.fn(),
};
vi.mock('./useAdminData', () => ({
useAdminData: () => mockAdminData,
}));
// Import notifications for assertions
import { notifications } from '@mantine/notifications';
// Mock user data
const mockUsers: User[] = [
{
id: 1,
email: 'admin@example.com',
displayName: 'Admin User',
role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
},
{
id: 2,
email: 'editor@example.com',
displayName: 'Editor User',
role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-02T00:00:00Z',
lastWorkspaceId: 1,
},
];
// Helper function to get a user by index and ensure it's not undefined
const getUser = (index: number): User => {
const user = mockUsers[index];
if (!user) {
throw new Error(`User at index ${index} is undefined`);
}
return user;
};
describe('useUserAdmin', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock data
mockAdminData.data = [...mockUsers];
mockAdminData.loading = false;
mockAdminData.error = null;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initial state', () => {
it('returns users data from useAdminData', () => {
const { result } = renderHook(() => useUserAdmin());
expect(result.current.users).toEqual(mockUsers);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it('returns loading state from useAdminData', () => {
mockAdminData.loading = true;
const { result } = renderHook(() => useUserAdmin());
expect(result.current.loading).toBe(true);
});
it('returns error state from useAdminData', () => {
mockAdminData.error = 'Failed to load users';
const { result } = renderHook(() => useUserAdmin());
expect(result.current.error).toBe('Failed to load users');
});
it('provides CRUD functions', () => {
const { result } = renderHook(() => useUserAdmin());
expect(typeof result.current.create).toBe('function');
expect(typeof result.current.update).toBe('function');
expect(typeof result.current.delete).toBe('function');
});
});
describe('create user', () => {
it('creates user successfully', async () => {
const mockCreateUser = vi.mocked(adminApi.createUser);
const newUser: User = {
id: 3,
email: 'newuser@example.com',
displayName: 'New User',
role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1,
};
mockCreateUser.mockResolvedValue(newUser);
const { result } = renderHook(() => useUserAdmin());
const createRequest: CreateUserRequest = {
email: 'newuser@example.com',
displayName: 'New User',
password: 'password123',
role: UserRole.Viewer,
theme: Theme.Dark,
};
let createResult: boolean | undefined;
await act(async () => {
createResult = await result.current.create(createRequest);
});
expect(createResult).toBe(true);
expect(mockCreateUser).toHaveBeenCalledWith(createRequest);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Success',
message: 'User created successfully',
color: 'green',
});
expect(mockAdminData.reload).toHaveBeenCalled();
});
it('handles create errors with specific message', async () => {
const mockCreateUser = vi.mocked(adminApi.createUser);
mockCreateUser.mockRejectedValue(new Error('Email already exists'));
const { result } = renderHook(() => useUserAdmin());
const createRequest: CreateUserRequest = {
email: 'existing@example.com',
displayName: 'Test User',
password: 'password123',
role: UserRole.Editor,
theme: Theme.Dark,
};
let createResult: boolean | undefined;
await act(async () => {
createResult = await result.current.create(createRequest);
});
expect(createResult).toBe(false);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Error',
message: 'Failed to create user: Email already exists',
color: 'red',
});
expect(mockAdminData.reload).not.toHaveBeenCalled();
});
it('handles create errors with non-Error rejection', async () => {
const mockCreateUser = vi.mocked(adminApi.createUser);
mockCreateUser.mockRejectedValue('String error');
const { result } = renderHook(() => useUserAdmin());
const createRequest: CreateUserRequest = {
email: 'test@example.com',
displayName: 'Test User',
password: 'password123',
role: UserRole.Editor,
theme: Theme.Dark,
};
let createResult: boolean | undefined;
await act(async () => {
createResult = await result.current.create(createRequest);
});
expect(createResult).toBe(false);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Error',
message: 'Failed to create user: String error',
color: 'red',
});
});
});
describe('update user', () => {
it('updates user successfully', async () => {
const mockUpdateUser = vi.mocked(adminApi.updateUser);
const user = getUser(1);
const updatedUser: User = {
id: user.id,
email: user.email,
displayName: 'Updated Editor',
role: user.role,
theme: user.theme,
createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId,
};
mockUpdateUser.mockResolvedValue(updatedUser);
const { result } = renderHook(() => useUserAdmin());
const updateRequest: UpdateUserRequest = {
displayName: 'Updated Editor',
};
let updateResult: boolean | undefined;
await act(async () => {
updateResult = await result.current.update(2, updateRequest);
});
expect(updateResult).toBe(true);
expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Success',
message: 'User updated successfully',
color: 'green',
});
expect(mockAdminData.reload).toHaveBeenCalled();
});
it('updates user email and role', async () => {
const mockUpdateUser = vi.mocked(adminApi.updateUser);
const user = getUser(1);
const updatedUser: User = {
id: user.id,
email: 'newemail@example.com',
displayName: user.displayName || '',
role: UserRole.Admin,
theme: Theme.Dark,
createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId,
};
mockUpdateUser.mockResolvedValue(updatedUser);
const { result } = renderHook(() => useUserAdmin());
const updateRequest: UpdateUserRequest = {
email: 'newemail@example.com',
role: UserRole.Admin,
theme: Theme.Dark,
};
let updateResult: boolean | undefined;
await act(async () => {
updateResult = await result.current.update(2, updateRequest);
});
expect(updateResult).toBe(true);
expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest);
});
it('updates user password', async () => {
const mockUpdateUser = vi.mocked(adminApi.updateUser);
mockUpdateUser.mockResolvedValue(getUser(1));
const { result } = renderHook(() => useUserAdmin());
const updateRequest: UpdateUserRequest = {
password: 'newpassword123',
};
let updateResult: boolean | undefined;
await act(async () => {
updateResult = await result.current.update(2, updateRequest);
});
expect(updateResult).toBe(true);
expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest);
});
it('handles update errors', async () => {
const mockUpdateUser = vi.mocked(adminApi.updateUser);
mockUpdateUser.mockRejectedValue(new Error('User not found'));
const { result } = renderHook(() => useUserAdmin());
const updateRequest: UpdateUserRequest = {
displayName: 'Updated Name',
};
let updateResult: boolean | undefined;
await act(async () => {
updateResult = await result.current.update(999, updateRequest);
});
expect(updateResult).toBe(false);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Error',
message: 'Failed to update user: User not found',
color: 'red',
});
expect(mockAdminData.reload).not.toHaveBeenCalled();
});
it('handles empty update request', async () => {
const mockUpdateUser = vi.mocked(adminApi.updateUser);
mockUpdateUser.mockResolvedValue(getUser(1));
const { result } = renderHook(() => useUserAdmin());
let updateResult: boolean | undefined;
await act(async () => {
updateResult = await result.current.update(2, {});
});
expect(updateResult).toBe(true);
expect(mockUpdateUser).toHaveBeenCalledWith(2, {});
});
});
describe('delete user', () => {
it('deletes user successfully', async () => {
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
mockDeleteUser.mockResolvedValue(undefined);
const { result } = renderHook(() => useUserAdmin());
let deleteResult: boolean | undefined;
await act(async () => {
deleteResult = await result.current.delete(2);
});
expect(deleteResult).toBe(true);
expect(mockDeleteUser).toHaveBeenCalledWith(2);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Success',
message: 'User deleted successfully',
color: 'green',
});
expect(mockAdminData.reload).toHaveBeenCalled();
});
it('handles delete errors', async () => {
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
mockDeleteUser.mockRejectedValue(new Error('Cannot delete admin user'));
const { result } = renderHook(() => useUserAdmin());
let deleteResult: boolean | undefined;
await act(async () => {
deleteResult = await result.current.delete(1);
});
expect(deleteResult).toBe(false);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Error',
message: 'Failed to delete user: Cannot delete admin user',
color: 'red',
});
expect(mockAdminData.reload).not.toHaveBeenCalled();
});
it('handles delete with non-existent user', async () => {
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
mockDeleteUser.mockRejectedValue(new Error('User not found'));
const { result } = renderHook(() => useUserAdmin());
let deleteResult: boolean | undefined;
await act(async () => {
deleteResult = await result.current.delete(999);
});
expect(deleteResult).toBe(false);
expect(notifications.show).toHaveBeenCalledWith({
title: 'Error',
message: 'Failed to delete user: User not found',
color: 'red',
});
});
});
describe('data integration', () => {
it('reflects loading state changes', () => {
const { result, rerender } = renderHook(() => useUserAdmin());
expect(result.current.loading).toBe(false);
// Change loading state
mockAdminData.loading = true;
rerender();
expect(result.current.loading).toBe(true);
});
it('reflects error state changes', () => {
const { result, rerender } = renderHook(() => useUserAdmin());
expect(result.current.error).toBeNull();
// Add error
mockAdminData.error = 'Network error';
rerender();
expect(result.current.error).toBe('Network error');
});
it('reflects data changes', () => {
const { result, rerender } = renderHook(() => useUserAdmin());
expect(result.current.users).toEqual(mockUsers);
// Change users data
const newUsers = [mockUsers[0]].filter((u): u is User => u !== undefined);
mockAdminData.data = newUsers;
rerender();
expect(result.current.users).toEqual(newUsers);
});
it('calls reload after successful operations', async () => {
const mockCreateUser = vi.mocked(adminApi.createUser);
const mockUpdateUser = vi.mocked(adminApi.updateUser);
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
mockCreateUser.mockResolvedValue(getUser(0));
mockUpdateUser.mockResolvedValue(getUser(0));
mockDeleteUser.mockResolvedValue(undefined);
const { result } = renderHook(() => useUserAdmin());
// Test create
await act(async () => {
await result.current.create({
email: 'test@example.com',
displayName: 'Test',
password: 'pass',
role: UserRole.Viewer,
theme: Theme.Dark,
});
});
expect(mockAdminData.reload).toHaveBeenCalledTimes(1);
// Test update
await act(async () => {
await result.current.update(1, { displayName: 'Updated' });
});
expect(mockAdminData.reload).toHaveBeenCalledTimes(2);
// Test delete
await act(async () => {
await result.current.delete(1);
});
expect(mockAdminData.reload).toHaveBeenCalledTimes(3);
});
it('does not call reload after failed operations', async () => {
const mockCreateUser = vi.mocked(adminApi.createUser);
const mockUpdateUser = vi.mocked(adminApi.updateUser);
const mockDeleteUser = vi.mocked(adminApi.deleteUser);
mockCreateUser.mockRejectedValue(new Error('Create failed'));
mockUpdateUser.mockRejectedValue(new Error('Update failed'));
mockDeleteUser.mockRejectedValue(new Error('Delete failed'));
const { result } = renderHook(() => useUserAdmin());
// Test failed create
await act(async () => {
await result.current.create({
email: 'test@example.com',
displayName: 'Test',
password: 'pass',
role: UserRole.Viewer,
theme: Theme.Dark,
});
});
// Test failed update
await act(async () => {
await result.current.update(1, { displayName: 'Updated' });
});
// Test failed delete
await act(async () => {
await result.current.delete(1);
});
expect(mockAdminData.reload).not.toHaveBeenCalled();
});
});
describe('concurrent operations', () => {
it('handles multiple create operations', async () => {
const mockCreateUser = vi.mocked(adminApi.createUser);
mockCreateUser
.mockResolvedValueOnce({
id: 3,
email: 'user1@example.com',
displayName: 'User 1',
role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1,
})
.mockResolvedValueOnce({
id: 4,
email: 'user2@example.com',
displayName: 'User 2',
role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-04T00:00:00Z',
lastWorkspaceId: 1,
});
const { result } = renderHook(() => useUserAdmin());
const requests = [
{
email: 'user1@example.com',
displayName: 'User 1',
password: 'pass1',
role: UserRole.Viewer,
theme: Theme.Dark,
},
{
email: 'user2@example.com',
displayName: 'User 2',
password: 'pass2',
role: UserRole.Editor,
theme: Theme.Dark,
},
];
let results: boolean[] = [];
await act(async () => {
results = await Promise.all(
requests.map((req) => result.current.create(req))
);
});
expect(results).toEqual([true, true]);
expect(mockCreateUser).toHaveBeenCalledTimes(2);
expect(mockAdminData.reload).toHaveBeenCalledTimes(2);
});
it('handles mixed successful and failed operations', async () => {
const mockCreateUser = vi.mocked(adminApi.createUser);
mockCreateUser
.mockResolvedValueOnce(getUser(0))
.mockRejectedValueOnce(new Error('Second create failed'));
const { result } = renderHook(() => useUserAdmin());
const requests = [
{
email: 'success@example.com',
displayName: 'Success User',
password: 'pass1',
role: UserRole.Viewer,
theme: Theme.Dark,
},
{
email: 'fail@example.com',
displayName: 'Fail User',
password: 'pass2',
role: UserRole.Editor,
theme: Theme.Dark,
},
];
let results: boolean[] = [];
await act(async () => {
results = await Promise.all(
requests.map((req) => result.current.create(req))
);
});
expect(results).toEqual([true, false]);
expect(mockAdminData.reload).toHaveBeenCalledTimes(1); // Only for successful operation
});
});
describe('hook interface stability', () => {
it('functions are stable across re-renders', () => {
const { result, rerender } = renderHook(() => useUserAdmin());
const initialFunctions = {
create: result.current.create,
update: result.current.update,
delete: result.current.delete,
};
rerender();
expect(result.current.create).toBe(initialFunctions.create);
expect(result.current.update).toBe(initialFunctions.update);
expect(result.current.delete).toBe(initialFunctions.delete);
});
it('returns consistent interface', () => {
const { result } = renderHook(() => useUserAdmin());
expect(Array.isArray(result.current.users)).toBe(true);
expect(typeof result.current.loading).toBe('boolean');
expect(
result.current.error === null ||
typeof result.current.error === 'string'
).toBe(true);
expect(typeof result.current.create).toBe('function');
expect(typeof result.current.update).toBe('function');
expect(typeof result.current.delete).toBe('function');
});
});
});