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' }); }); }); });