Files
smoothschedule/frontend/src/hooks/__tests__/useInvitations.test.ts
poduck 416cd7059b Add global navigation search, cancellation policies, and UI improvements
- Add global search in top bar for navigating to dashboard pages
- Add cancellation policy settings (window hours, late fee, deposit refund)
- Display booking policies on customer confirmation page
- Filter API tokens by sandbox/live mode
- Widen settings layout and full-width site builder
- Add help documentation search with OpenAI integration
- Add blocked time ranges API for calendar visualization
- Update business hours settings with holiday management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 23:39:07 -05:00

328 lines
11 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';
import {
useInvitations,
useCreateInvitation,
useCancelInvitation,
useResendInvitation,
useInvitationDetails,
useAcceptInvitation,
useDeclineInvitation,
} from '../useInvitations';
import apiClient from '../../api/client';
vi.mock('../../api/client');
const mockInvitation = {
id: 1,
email: 'invite@example.com',
role: 'TENANT_STAFF' as const,
role_display: 'Staff Member',
status: 'PENDING' as const,
invited_by: 1,
invited_by_name: 'Admin',
created_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z',
accepted_at: null,
create_bookable_resource: false,
resource_name: '',
permissions: {},
staff_role_id: null,
staff_role_name: null,
};
const mockInvitationDetails = {
email: 'invite@example.com',
role: 'TENANT_STAFF',
role_display: 'Staff Member',
business_name: 'Test Business',
invited_by: 'Admin',
expires_at: '2024-01-08T00:00:00Z',
create_bookable_resource: false,
resource_name: '',
invitation_type: 'staff' as const,
};
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', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: [mockInvitation] });
const { result } = renderHook(() => useInvitations(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/staff/invitations/');
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0].email).toBe('invite@example.com');
});
it('handles error when fetching invitations', async () => {
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Failed to fetch'));
const { result } = renderHook(() => useInvitations(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
describe('useCreateInvitation', () => {
it('creates a new invitation', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockInvitation });
const { result } = renderHook(() => useCreateInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
email: 'new@example.com',
role: 'TENANT_STAFF',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', {
email: 'new@example.com',
role: 'TENANT_STAFF',
});
});
it('creates invitation with bookable resource', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockInvitation });
const { result } = renderHook(() => useCreateInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
email: 'new@example.com',
role: 'TENANT_STAFF',
create_bookable_resource: true,
resource_name: 'John Doe',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', expect.objectContaining({
create_bookable_resource: true,
resource_name: 'John Doe',
}));
});
it('creates invitation with staff role', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockInvitation });
const { result } = renderHook(() => useCreateInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
email: 'new@example.com',
role: 'TENANT_STAFF',
staff_role_id: 1,
});
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/', expect.objectContaining({
staff_role_id: 1,
}));
});
});
describe('useCancelInvitation', () => {
it('cancels an invitation', async () => {
vi.mocked(apiClient.delete).mockResolvedValueOnce({});
const { result } = renderHook(() => useCancelInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(apiClient.delete).toHaveBeenCalledWith('/staff/invitations/1/');
});
});
describe('useResendInvitation', () => {
it('resends an invitation', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useResendInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync(1);
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/1/resend/');
});
});
describe('useInvitationDetails', () => {
it('does not fetch when token is null', () => {
const { result } = renderHook(() => useInvitationDetails(null), { wrapper: createWrapper() });
expect(result.current.fetchStatus).toBe('idle');
expect(apiClient.get).not.toHaveBeenCalled();
});
it('fetches staff invitation details by token', async () => {
vi.mocked(apiClient.get)
.mockRejectedValueOnce(new Error('Not found'))
.mockResolvedValueOnce({ data: mockInvitationDetails });
const { result } = renderHook(() => useInvitationDetails('abc123'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.email).toBe('invite@example.com');
expect(result.current.data?.invitation_type).toBe('staff');
});
it('tries tenant invitation first', async () => {
const tenantDetails = { ...mockInvitationDetails, invitation_type: 'tenant' };
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: tenantDetails });
const { result } = renderHook(() => useInvitationDetails('abc123'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/platform/tenant-invitations/token/abc123/');
});
});
describe('useAcceptInvitation', () => {
it('accepts a staff invitation', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useAcceptInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
token: 'abc123',
firstName: 'John',
lastName: 'Doe',
password: 'password123',
invitationType: 'staff',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/token/abc123/accept/', {
first_name: 'John',
last_name: 'Doe',
password: 'password123',
});
});
it('accepts a tenant invitation', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useAcceptInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
token: 'abc123',
firstName: 'John',
lastName: 'Doe',
password: 'password123',
invitationType: 'tenant',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/platform/tenant-invitations/token/abc123/accept/', {
first_name: 'John',
last_name: 'Doe',
password: 'password123',
});
});
it('tries tenant first when type not specified', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useAcceptInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
token: 'abc123',
firstName: 'John',
lastName: 'Doe',
password: 'password123',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/platform/tenant-invitations/token/abc123/accept/', expect.anything());
});
it('falls back to staff when tenant fails', async () => {
vi.mocked(apiClient.post)
.mockRejectedValueOnce(new Error('Not found'))
.mockResolvedValueOnce({ data: { success: true } });
const { result } = renderHook(() => useAcceptInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
token: 'abc123',
firstName: 'John',
lastName: 'Doe',
password: 'password123',
});
});
expect(apiClient.post).toHaveBeenCalledTimes(2);
expect(apiClient.post).toHaveBeenLastCalledWith('/staff/invitations/token/abc123/accept/', expect.anything());
});
});
describe('useDeclineInvitation', () => {
it('declines a staff invitation', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { status: 'declined' } });
const { result } = renderHook(() => useDeclineInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ token: 'abc123', invitationType: 'staff' });
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/invitations/token/abc123/decline/');
});
it('declines a tenant invitation', async () => {
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { status: 'declined' } });
const { result } = renderHook(() => useDeclineInvitation(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({ token: 'abc123', invitationType: 'tenant' });
});
expect(apiClient.post).toHaveBeenCalledWith('/platform/tenant-invitations/token/abc123/decline/');
});
it('returns success for tenant when decline endpoint fails', async () => {
vi.mocked(apiClient.post).mockRejectedValueOnce(new Error('Not found'));
const { result } = renderHook(() => useDeclineInvitation(), { wrapper: createWrapper() });
let response;
await act(async () => {
response = await result.current.mutateAsync({ token: 'abc123', invitationType: 'tenant' });
});
expect(response).toEqual({ status: 'declined' });
});
});
});