- 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>
328 lines
11 KiB
TypeScript
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' });
|
|
});
|
|
});
|
|
});
|