- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
686 lines
19 KiB
TypeScript
686 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import React from 'react';
|
|
|
|
// Mock apiClient
|
|
vi.mock('../../api/client', () => ({
|
|
default: {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
patch: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import {
|
|
useUsers,
|
|
useStaffForAssignment,
|
|
usePlatformStaffForAssignment,
|
|
useUpdateStaffPermissions,
|
|
} from '../useUsers';
|
|
import apiClient from '../../api/client';
|
|
|
|
// Create wrapper
|
|
const createWrapper = () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
};
|
|
};
|
|
|
|
describe('useUsers hooks', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('useUsers', () => {
|
|
it('fetches all staff members', async () => {
|
|
const mockStaff = [
|
|
{
|
|
id: 1,
|
|
email: 'owner@example.com',
|
|
name: 'John Owner',
|
|
username: 'jowner',
|
|
role: 'owner',
|
|
is_active: true,
|
|
permissions: { can_access_resources: true },
|
|
can_invite_staff: true,
|
|
},
|
|
{
|
|
id: 2,
|
|
email: 'manager@example.com',
|
|
name: 'Jane Manager',
|
|
username: 'jmanager',
|
|
role: 'manager',
|
|
is_active: true,
|
|
permissions: { can_access_services: false },
|
|
can_invite_staff: false,
|
|
},
|
|
{
|
|
id: 3,
|
|
email: 'staff@example.com',
|
|
name: 'Bob Staff',
|
|
username: 'bstaff',
|
|
role: 'staff',
|
|
is_active: false,
|
|
permissions: {},
|
|
can_invite_staff: false,
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
|
|
|
|
const { result } = renderHook(() => useUsers(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
|
|
expect(result.current.data).toHaveLength(3);
|
|
expect(result.current.data).toEqual(mockStaff);
|
|
});
|
|
|
|
it('returns empty array when no staff members exist', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
const { result } = renderHook(() => useUsers(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
|
|
expect(result.current.data).toEqual([]);
|
|
});
|
|
|
|
it('handles API errors', async () => {
|
|
const errorMessage = 'Failed to fetch staff';
|
|
vi.mocked(apiClient.get).mockRejectedValue(new Error(errorMessage));
|
|
|
|
const { result } = renderHook(() => useUsers(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBeDefined();
|
|
});
|
|
|
|
it('uses correct query key', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
const { result } = renderHook(() => useUsers(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// Query key should be ['staff'] for caching and invalidation
|
|
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
|
|
});
|
|
});
|
|
|
|
describe('useStaffForAssignment', () => {
|
|
it('fetches and transforms staff for dropdown use', async () => {
|
|
const mockStaff = [
|
|
{
|
|
id: 1,
|
|
email: 'john@example.com',
|
|
name: 'John Doe',
|
|
role: 'owner',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: 2,
|
|
email: 'jane@example.com',
|
|
name: 'Jane Smith',
|
|
role: 'manager',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
|
|
|
|
const { result } = renderHook(() => useStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
|
|
expect(result.current.data).toEqual([
|
|
{
|
|
id: '1',
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
role: 'owner',
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Jane Smith',
|
|
email: 'jane@example.com',
|
|
role: 'manager',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('converts id to string', async () => {
|
|
const mockStaff = [
|
|
{
|
|
id: 123,
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
role: 'staff',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
|
|
|
|
const { result } = renderHook(() => useStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.[0].id).toBe('123');
|
|
expect(typeof result.current.data?.[0].id).toBe('string');
|
|
});
|
|
|
|
it('falls back to email when name is not provided', async () => {
|
|
const mockStaff = [
|
|
{
|
|
id: 1,
|
|
email: 'noname@example.com',
|
|
name: null,
|
|
role: 'staff',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: 2,
|
|
email: 'emptyname@example.com',
|
|
name: '',
|
|
role: 'staff',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
|
|
|
|
const { result } = renderHook(() => useStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.[0].name).toBe('noname@example.com');
|
|
expect(result.current.data?.[1].name).toBe('emptyname@example.com');
|
|
});
|
|
|
|
it('includes all roles (owner, manager, staff)', async () => {
|
|
const mockStaff = [
|
|
{
|
|
id: 1,
|
|
email: 'owner@example.com',
|
|
name: 'Owner User',
|
|
role: 'owner',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: 2,
|
|
email: 'manager@example.com',
|
|
name: 'Manager User',
|
|
role: 'manager',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
{
|
|
id: 3,
|
|
email: 'staff@example.com',
|
|
name: 'Staff User',
|
|
role: 'staff',
|
|
is_active: true,
|
|
permissions: {},
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
|
|
|
|
const { result } = renderHook(() => useStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toHaveLength(3);
|
|
expect(result.current.data?.map(u => u.role)).toEqual(['owner', 'manager', 'staff']);
|
|
});
|
|
|
|
it('returns empty array when no staff exist', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
const { result } = renderHook(() => useStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('usePlatformStaffForAssignment', () => {
|
|
it('fetches and filters platform staff by role', async () => {
|
|
const mockPlatformUsers = [
|
|
{
|
|
id: 1,
|
|
email: 'super@platform.com',
|
|
name: 'Super User',
|
|
role: 'superuser',
|
|
},
|
|
{
|
|
id: 2,
|
|
email: 'manager@platform.com',
|
|
name: 'Platform Manager',
|
|
role: 'platform_manager',
|
|
},
|
|
{
|
|
id: 3,
|
|
email: 'support@platform.com',
|
|
name: 'Platform Support',
|
|
role: 'platform_support',
|
|
},
|
|
{
|
|
id: 4,
|
|
email: 'owner@business.com',
|
|
name: 'Business Owner',
|
|
role: 'owner',
|
|
},
|
|
{
|
|
id: 5,
|
|
email: 'staff@business.com',
|
|
name: 'Business Staff',
|
|
role: 'staff',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
|
|
|
|
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/');
|
|
|
|
// Should only return platform roles
|
|
expect(result.current.data).toHaveLength(3);
|
|
expect(result.current.data?.map(u => u.role)).toEqual([
|
|
'superuser',
|
|
'platform_manager',
|
|
'platform_support',
|
|
]);
|
|
});
|
|
|
|
it('transforms platform users for dropdown use', async () => {
|
|
const mockPlatformUsers = [
|
|
{
|
|
id: 10,
|
|
email: 'admin@platform.com',
|
|
name: 'Admin User',
|
|
role: 'superuser',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
|
|
|
|
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual([
|
|
{
|
|
id: '10',
|
|
name: 'Admin User',
|
|
email: 'admin@platform.com',
|
|
role: 'superuser',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('filters out non-platform roles', async () => {
|
|
const mockPlatformUsers = [
|
|
{ id: 1, email: 'super@platform.com', name: 'Super', role: 'superuser' },
|
|
{ id: 2, email: 'owner@business.com', name: 'Owner', role: 'owner' },
|
|
{ id: 3, email: 'manager@business.com', name: 'Manager', role: 'manager' },
|
|
{ id: 4, email: 'staff@business.com', name: 'Staff', role: 'staff' },
|
|
{ id: 5, email: 'resource@business.com', name: 'Resource', role: 'resource' },
|
|
{ id: 6, email: 'customer@business.com', name: 'Customer', role: 'customer' },
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
|
|
|
|
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// Only superuser should be included from the mock data
|
|
expect(result.current.data).toHaveLength(1);
|
|
expect(result.current.data?.[0].role).toBe('superuser');
|
|
});
|
|
|
|
it('includes all three platform roles', async () => {
|
|
const mockPlatformUsers = [
|
|
{ id: 1, email: 'super@platform.com', name: 'Super', role: 'superuser' },
|
|
{ id: 2, email: 'pm@platform.com', name: 'PM', role: 'platform_manager' },
|
|
{ id: 3, email: 'support@platform.com', name: 'Support', role: 'platform_support' },
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
|
|
|
|
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
const roles = result.current.data?.map(u => u.role);
|
|
expect(roles).toContain('superuser');
|
|
expect(roles).toContain('platform_manager');
|
|
expect(roles).toContain('platform_support');
|
|
});
|
|
|
|
it('falls back to email when name is missing', async () => {
|
|
const mockPlatformUsers = [
|
|
{
|
|
id: 1,
|
|
email: 'noname@platform.com',
|
|
role: 'superuser',
|
|
},
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
|
|
|
|
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data?.[0].name).toBe('noname@platform.com');
|
|
});
|
|
|
|
it('returns empty array when no platform users exist', async () => {
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
|
|
|
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array when only non-platform users exist', async () => {
|
|
const mockPlatformUsers = [
|
|
{ id: 1, email: 'owner@business.com', name: 'Owner', role: 'owner' },
|
|
{ id: 2, email: 'staff@business.com', name: 'Staff', role: 'staff' },
|
|
];
|
|
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
|
|
|
|
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(result.current.data).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('useUpdateStaffPermissions', () => {
|
|
it('updates staff permissions', async () => {
|
|
const updatedStaff = {
|
|
id: 5,
|
|
email: 'staff@example.com',
|
|
name: 'Staff User',
|
|
role: 'staff',
|
|
is_active: true,
|
|
permissions: {
|
|
can_access_resources: true,
|
|
can_access_services: false,
|
|
},
|
|
};
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedStaff });
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
userId: 5,
|
|
permissions: {
|
|
can_access_resources: true,
|
|
can_access_services: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff/5/', {
|
|
permissions: {
|
|
can_access_resources: true,
|
|
can_access_services: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('accepts string userId', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
userId: '42',
|
|
permissions: { can_access_resources: true },
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff/42/', {
|
|
permissions: { can_access_resources: true },
|
|
});
|
|
});
|
|
|
|
it('accepts number userId', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
userId: 123,
|
|
permissions: { can_list_customers: true },
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff/123/', {
|
|
permissions: { can_list_customers: true },
|
|
});
|
|
});
|
|
|
|
it('can update multiple permissions at once', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
const permissions = {
|
|
can_access_resources: true,
|
|
can_access_services: true,
|
|
can_list_customers: false,
|
|
can_access_scheduled_tasks: false,
|
|
};
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
userId: 1,
|
|
permissions,
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff/1/', {
|
|
permissions,
|
|
});
|
|
});
|
|
|
|
it('can set permissions to empty object', async () => {
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
userId: 1,
|
|
permissions: {},
|
|
});
|
|
});
|
|
|
|
expect(apiClient.patch).toHaveBeenCalledWith('/staff/1/', {
|
|
permissions: {},
|
|
});
|
|
});
|
|
|
|
it('invalidates staff query on success', async () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
userId: 1,
|
|
permissions: { can_access_resources: true },
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['staff'] });
|
|
});
|
|
});
|
|
|
|
it('handles API errors', async () => {
|
|
const errorMessage = 'Permission update failed';
|
|
vi.mocked(apiClient.patch).mockRejectedValue(new Error(errorMessage));
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({
|
|
userId: 1,
|
|
permissions: { can_access_resources: true },
|
|
});
|
|
} catch (error) {
|
|
expect(error).toBeDefined();
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns updated data from mutation', async () => {
|
|
const updatedStaff = {
|
|
id: 10,
|
|
email: 'updated@example.com',
|
|
name: 'Updated User',
|
|
role: 'staff',
|
|
is_active: true,
|
|
permissions: {
|
|
can_access_resources: true,
|
|
},
|
|
};
|
|
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedStaff });
|
|
|
|
const { result } = renderHook(() => useUpdateStaffPermissions(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let mutationResult;
|
|
await act(async () => {
|
|
mutationResult = await result.current.mutateAsync({
|
|
userId: 10,
|
|
permissions: { can_access_resources: true },
|
|
});
|
|
});
|
|
|
|
expect(mutationResult).toEqual(updatedStaff);
|
|
});
|
|
});
|
|
});
|