feat: Add comprehensive test suite and misc improvements
- 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>
This commit is contained in:
902
frontend/src/hooks/__tests__/useInvitations.test.ts
Normal file
902
frontend/src/hooks/__tests__/useInvitations.test.ts
Normal file
@@ -0,0 +1,902 @@
|
||||
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(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
useInvitations,
|
||||
useCreateInvitation,
|
||||
useCancelInvitation,
|
||||
useResendInvitation,
|
||||
useInvitationDetails,
|
||||
useAcceptInvitation,
|
||||
useDeclineInvitation,
|
||||
StaffInvitation,
|
||||
InvitationDetails,
|
||||
CreateInvitationData,
|
||||
} from '../useInvitations';
|
||||
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('useInvitations hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useInvitations', () => {
|
||||
it('fetches pending invitations successfully', async () => {
|
||||
const mockInvitations: StaffInvitation[] = [
|
||||
{
|
||||
id: 1,
|
||||
email: 'john@example.com',
|
||||
role: 'TENANT_MANAGER',
|
||||
role_display: 'Manager',
|
||||
status: 'PENDING',
|
||||
invited_by: 5,
|
||||
invited_by_name: 'Admin User',
|
||||
created_at: '2024-01-01T10:00:00Z',
|
||||
expires_at: '2024-01-08T10:00:00Z',
|
||||
accepted_at: null,
|
||||
create_bookable_resource: false,
|
||||
resource_name: '',
|
||||
permissions: { can_invite_staff: true },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
email: 'jane@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
role_display: 'Staff',
|
||||
status: 'PENDING',
|
||||
invited_by: 5,
|
||||
invited_by_name: 'Admin User',
|
||||
created_at: '2024-01-02T10:00:00Z',
|
||||
expires_at: '2024-01-09T10:00:00Z',
|
||||
accepted_at: null,
|
||||
create_bookable_resource: true,
|
||||
resource_name: 'Jane',
|
||||
permissions: {},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockInvitations });
|
||||
|
||||
const { result } = renderHook(() => useInvitations(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/staff/invitations/');
|
||||
expect(result.current.data).toEqual(mockInvitations);
|
||||
expect(result.current.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns empty array when no invitations exist', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const { result } = renderHook(() => useInvitations(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useInvitations(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses correct query key for cache management', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
renderHook(() => useInvitations(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
const cache = queryClient.getQueryCache();
|
||||
const queries = cache.findAll({ queryKey: ['invitations'] });
|
||||
expect(queries.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateInvitation', () => {
|
||||
it('creates invitation with minimal data', async () => {
|
||||
const invitationData: CreateInvitationData = {
|
||||
email: 'new@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: 3,
|
||||
email: 'new@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
status: 'PENDING',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useCreateInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(invitationData);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData);
|
||||
});
|
||||
|
||||
it('creates invitation with full data including resource', async () => {
|
||||
const invitationData: CreateInvitationData = {
|
||||
email: 'staff@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
create_bookable_resource: true,
|
||||
resource_name: 'New Staff Member',
|
||||
permissions: {
|
||||
can_view_all_schedules: true,
|
||||
can_manage_own_appointments: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: 4,
|
||||
email: 'staff@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
create_bookable_resource: true,
|
||||
resource_name: 'New Staff Member',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useCreateInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(invitationData);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData);
|
||||
});
|
||||
|
||||
it('creates manager invitation with permissions', async () => {
|
||||
const invitationData: CreateInvitationData = {
|
||||
email: 'manager@example.com',
|
||||
role: 'TENANT_MANAGER',
|
||||
permissions: {
|
||||
can_invite_staff: true,
|
||||
can_manage_resources: true,
|
||||
can_manage_services: true,
|
||||
can_view_reports: true,
|
||||
can_access_settings: false,
|
||||
can_refund_payments: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse = { id: 5, email: 'manager@example.com', role: 'TENANT_MANAGER' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useCreateInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(invitationData);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', invitationData);
|
||||
});
|
||||
|
||||
it('invalidates invitations query on success', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => useCreateInvitation(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
email: 'test@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
});
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['invitations'] });
|
||||
});
|
||||
|
||||
it('handles API errors during creation', async () => {
|
||||
const errorMessage = 'Email already invited';
|
||||
vi.mocked(apiClient.post).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useCreateInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
email: 'duplicate@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
});
|
||||
} catch (error) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect(caughtError?.message).toBe(errorMessage);
|
||||
});
|
||||
|
||||
it('returns created invitation data', async () => {
|
||||
const mockResponse = {
|
||||
id: 10,
|
||||
email: 'created@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
status: 'PENDING',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useCreateInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let responseData;
|
||||
await act(async () => {
|
||||
responseData = await result.current.mutateAsync({
|
||||
email: 'created@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
});
|
||||
});
|
||||
|
||||
expect(responseData).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCancelInvitation', () => {
|
||||
it('cancels invitation by id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useCancelInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/staff/invitations/1/');
|
||||
});
|
||||
|
||||
it('invalidates invitations query on success', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => useCancelInvitation(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(5);
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['invitations'] });
|
||||
});
|
||||
|
||||
it('handles API errors during cancellation', async () => {
|
||||
const errorMessage = 'Invitation not found';
|
||||
vi.mocked(apiClient.delete).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useCancelInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync(999);
|
||||
} catch (error) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect(caughtError?.message).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useResendInvitation', () => {
|
||||
it('resends invitation email', async () => {
|
||||
const mockResponse = { message: 'Invitation email sent' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useResendInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(2);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/2/resend/');
|
||||
});
|
||||
|
||||
it('returns response data', async () => {
|
||||
const mockResponse = { message: 'Email resent successfully', sent_at: '2024-01-01T12:00:00Z' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useResendInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let responseData;
|
||||
await act(async () => {
|
||||
responseData = await result.current.mutateAsync(3);
|
||||
});
|
||||
|
||||
expect(responseData).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('handles API errors during resend', async () => {
|
||||
const errorMessage = 'Invitation already accepted';
|
||||
vi.mocked(apiClient.post).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useResendInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync(10);
|
||||
} catch (error) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect(caughtError?.message).toBe(errorMessage);
|
||||
});
|
||||
|
||||
it('does not invalidate queries (resend does not modify invitation list)', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
const { result } = renderHook(() => useResendInvitation(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
// Should not invalidate invitations query
|
||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useInvitationDetails', () => {
|
||||
it('fetches platform tenant invitation first and returns with tenant type', async () => {
|
||||
const mockPlatformInvitation: Omit<InvitationDetails, 'invitation_type'> = {
|
||||
email: 'tenant@example.com',
|
||||
role: 'OWNER',
|
||||
role_display: 'Business Owner',
|
||||
business_name: 'New Business',
|
||||
invited_by: 'Platform Admin',
|
||||
expires_at: '2024-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformInvitation });
|
||||
|
||||
const { result } = renderHook(() => useInvitationDetails('valid-token-123'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/token/valid-token-123/');
|
||||
expect(result.current.data).toEqual({
|
||||
...mockPlatformInvitation,
|
||||
invitation_type: 'tenant',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to staff invitation when platform request fails', async () => {
|
||||
const mockStaffInvitation: Omit<InvitationDetails, 'invitation_type'> = {
|
||||
email: 'staff@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
role_display: 'Staff',
|
||||
business_name: 'Existing Business',
|
||||
invited_by: 'Manager',
|
||||
expires_at: '2024-01-15T10:00:00Z',
|
||||
create_bookable_resource: true,
|
||||
resource_name: 'Staff Member',
|
||||
};
|
||||
|
||||
// First call fails (platform), second succeeds (staff)
|
||||
vi.mocked(apiClient.get)
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce({ data: mockStaffInvitation });
|
||||
|
||||
const { result } = renderHook(() => useInvitationDetails('staff-token-456'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/token/staff-token-456/');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/staff/invitations/token/staff-token-456/');
|
||||
expect(result.current.data).toEqual({
|
||||
...mockStaffInvitation,
|
||||
invitation_type: 'staff',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when both platform and staff requests fail', async () => {
|
||||
vi.mocked(apiClient.get)
|
||||
.mockRejectedValueOnce(new Error('Platform not found'))
|
||||
.mockRejectedValueOnce(new Error('Staff not found'));
|
||||
|
||||
const { result } = renderHook(() => useInvitationDetails('invalid-token'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not fetch when token is null', async () => {
|
||||
const { result } = renderHook(() => useInvitationDetails(null), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not fetch when token is empty string', async () => {
|
||||
const { result } = renderHook(() => useInvitationDetails(''), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry on failure', async () => {
|
||||
vi.mocked(apiClient.get)
|
||||
.mockRejectedValueOnce(new Error('Platform error'))
|
||||
.mockRejectedValueOnce(new Error('Staff error'));
|
||||
|
||||
const { result } = renderHook(() => useInvitationDetails('token'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
// Called twice total: once for platform, once for staff (no retries)
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAcceptInvitation', () => {
|
||||
const acceptPayload = {
|
||||
token: 'test-token',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
password: 'SecurePass123!',
|
||||
};
|
||||
|
||||
it('accepts staff invitation when invitationType is staff', async () => {
|
||||
const mockResponse = { message: 'Invitation accepted', user_id: 1 };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useAcceptInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
...acceptPayload,
|
||||
invitationType: 'staff',
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/token/test-token/accept/', {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
password: 'SecurePass123!',
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('tries platform tenant invitation first when invitationType not provided', async () => {
|
||||
const mockResponse = { message: 'Tenant invitation accepted', business_id: 5 };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useAcceptInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(acceptPayload);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/platform/tenant-invitations/token/test-token/accept/', {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
password: 'SecurePass123!',
|
||||
});
|
||||
});
|
||||
|
||||
it('tries platform tenant invitation first when invitationType is tenant', async () => {
|
||||
const mockResponse = { message: 'Tenant invitation accepted' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useAcceptInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
...acceptPayload,
|
||||
invitationType: 'tenant',
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/platform/tenant-invitations/token/test-token/accept/', {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
password: 'SecurePass123!',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to staff invitation when platform request fails', async () => {
|
||||
const mockResponse = { message: 'Staff invitation accepted' };
|
||||
vi.mocked(apiClient.post)
|
||||
.mockRejectedValueOnce(new Error('Platform not found'))
|
||||
.mockResolvedValueOnce({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useAcceptInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(acceptPayload);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/platform/tenant-invitations/token/test-token/accept/', {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
password: 'SecurePass123!',
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/token/test-token/accept/', {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
password: 'SecurePass123!',
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws error when both platform and staff requests fail', async () => {
|
||||
vi.mocked(apiClient.post)
|
||||
.mockRejectedValueOnce(new Error('Platform error'))
|
||||
.mockRejectedValueOnce(new Error('Staff error'));
|
||||
|
||||
const { result } = renderHook(() => useAcceptInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync(acceptPayload);
|
||||
} catch (error) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect(caughtError?.message).toBe('Staff error');
|
||||
});
|
||||
|
||||
it('returns response data on successful acceptance', async () => {
|
||||
const mockResponse = {
|
||||
message: 'Success',
|
||||
user: { id: 1, email: 'john@example.com' },
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useAcceptInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let responseData;
|
||||
await act(async () => {
|
||||
responseData = await result.current.mutateAsync({
|
||||
...acceptPayload,
|
||||
invitationType: 'staff',
|
||||
});
|
||||
});
|
||||
|
||||
expect(responseData).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeclineInvitation', () => {
|
||||
it('declines staff invitation', async () => {
|
||||
const mockResponse = { message: 'Invitation declined' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useDeclineInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
token: 'staff-token',
|
||||
invitationType: 'staff',
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/token/staff-token/decline/');
|
||||
});
|
||||
|
||||
it('attempts to decline tenant invitation', async () => {
|
||||
const mockResponse = { message: 'Tenant invitation declined' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useDeclineInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
token: 'tenant-token',
|
||||
invitationType: 'tenant',
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/platform/tenant-invitations/token/tenant-token/decline/');
|
||||
});
|
||||
|
||||
it('returns success status when tenant decline endpoint does not exist', async () => {
|
||||
vi.mocked(apiClient.post).mockRejectedValue(new Error('Not found'));
|
||||
|
||||
const { result } = renderHook(() => useDeclineInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let responseData;
|
||||
await act(async () => {
|
||||
responseData = await result.current.mutateAsync({
|
||||
token: 'tenant-token',
|
||||
invitationType: 'tenant',
|
||||
});
|
||||
});
|
||||
|
||||
expect(responseData).toEqual({ status: 'declined' });
|
||||
});
|
||||
|
||||
it('declines staff invitation when invitationType not provided', async () => {
|
||||
const mockResponse = { message: 'Staff invitation declined' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useDeclineInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
token: 'default-token',
|
||||
});
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/token/default-token/decline/');
|
||||
});
|
||||
|
||||
it('handles API errors for staff invitation decline', async () => {
|
||||
const errorMessage = 'Invitation already processed';
|
||||
vi.mocked(apiClient.post).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useDeclineInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
token: 'invalid-token',
|
||||
invitationType: 'staff',
|
||||
});
|
||||
} catch (error) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect(caughtError?.message).toBe(errorMessage);
|
||||
});
|
||||
|
||||
it('returns response data on successful decline', async () => {
|
||||
const mockResponse = {
|
||||
message: 'Successfully declined',
|
||||
invitation_id: 5,
|
||||
};
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const { result } = renderHook(() => useDeclineInvitation(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let responseData;
|
||||
await act(async () => {
|
||||
responseData = await result.current.mutateAsync({
|
||||
token: 'token',
|
||||
invitationType: 'staff',
|
||||
});
|
||||
});
|
||||
|
||||
expect(responseData).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and integration scenarios', () => {
|
||||
it('handles multiple invitation operations in sequence', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
// Mock responses
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined });
|
||||
|
||||
const { result: createResult } = renderHook(() => useCreateInvitation(), { wrapper });
|
||||
const { result: listResult } = renderHook(() => useInvitations(), { wrapper });
|
||||
const { result: cancelResult } = renderHook(() => useCancelInvitation(), { wrapper });
|
||||
|
||||
// Create invitation
|
||||
await act(async () => {
|
||||
await createResult.current.mutateAsync({
|
||||
email: 'test@example.com',
|
||||
role: 'TENANT_STAFF',
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel invitation
|
||||
await act(async () => {
|
||||
await cancelResult.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
// Verify list is called
|
||||
await waitFor(() => {
|
||||
expect(listResult.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalled();
|
||||
expect(apiClient.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles concurrent invitation details fetching with different tokens', async () => {
|
||||
const platformData = { email: 'platform@example.com', business_name: 'Platform Biz' };
|
||||
const staffData = { email: 'staff@example.com', business_name: 'Staff Biz' };
|
||||
|
||||
vi.mocked(apiClient.get)
|
||||
.mockResolvedValueOnce({ data: platformData })
|
||||
.mockRejectedValueOnce(new Error('Not found'))
|
||||
.mockResolvedValueOnce({ data: staffData });
|
||||
|
||||
const { result: result1 } = renderHook(() => useInvitationDetails('token1'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const { result: result2 } = renderHook(() => useInvitationDetails('token2'), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result1.current.isSuccess).toBe(true);
|
||||
expect(result2.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result1.current.data?.invitation_type).toBe('tenant');
|
||||
expect(result2.current.data?.invitation_type).toBe('staff');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user