- 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>
1197 lines
35 KiB
TypeScript
1197 lines
35 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 platform API
|
|
vi.mock('../../api/platform', () => ({
|
|
getBusinesses: vi.fn(),
|
|
getUsers: vi.fn(),
|
|
getBusinessUsers: vi.fn(),
|
|
updateBusiness: vi.fn(),
|
|
createBusiness: vi.fn(),
|
|
deleteBusiness: vi.fn(),
|
|
getTenantInvitations: vi.fn(),
|
|
createTenantInvitation: vi.fn(),
|
|
resendTenantInvitation: vi.fn(),
|
|
cancelTenantInvitation: vi.fn(),
|
|
getInvitationByToken: vi.fn(),
|
|
acceptInvitation: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
useBusinesses,
|
|
usePlatformUsers,
|
|
useBusinessUsers,
|
|
useUpdateBusiness,
|
|
useCreateBusiness,
|
|
useDeleteBusiness,
|
|
useTenantInvitations,
|
|
useCreateTenantInvitation,
|
|
useResendTenantInvitation,
|
|
useCancelTenantInvitation,
|
|
useInvitationByToken,
|
|
useAcceptInvitation,
|
|
} from '../usePlatform';
|
|
|
|
import * as platformApi from '../../api/platform';
|
|
|
|
// 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('usePlatform hooks', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// ============================================================================
|
|
// Query Hooks
|
|
// ============================================================================
|
|
|
|
describe('useBusinesses', () => {
|
|
it('fetches all businesses successfully', async () => {
|
|
const mockBusinesses = [
|
|
{
|
|
id: 1,
|
|
name: 'Business 1',
|
|
subdomain: 'biz1',
|
|
tier: 'PROFESSIONAL',
|
|
is_active: true,
|
|
created_on: '2024-01-01',
|
|
user_count: 5,
|
|
owner: {
|
|
id: 10,
|
|
username: 'owner1',
|
|
full_name: 'Owner One',
|
|
email: 'owner1@test.com',
|
|
role: 'OWNER',
|
|
email_verified: true,
|
|
},
|
|
max_users: 10,
|
|
max_resources: 20,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'Business 2',
|
|
subdomain: 'biz2',
|
|
tier: 'FREE',
|
|
is_active: true,
|
|
created_on: '2024-01-02',
|
|
user_count: 2,
|
|
owner: null,
|
|
max_users: 5,
|
|
max_resources: 10,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: false,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
},
|
|
];
|
|
|
|
vi.mocked(platformApi.getBusinesses).mockResolvedValue(mockBusinesses);
|
|
|
|
const { result } = renderHook(() => useBusinesses(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(platformApi.getBusinesses).toHaveBeenCalledOnce();
|
|
expect(result.current.data).toEqual(mockBusinesses);
|
|
expect(result.current.data).toHaveLength(2);
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
const mockError = new Error('Failed to fetch businesses');
|
|
vi.mocked(platformApi.getBusinesses).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useBusinesses(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBe(mockError);
|
|
});
|
|
|
|
it('uses correct query key', async () => {
|
|
vi.mocked(platformApi.getBusinesses).mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useBusinesses(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
// Query key should be ['platform', 'businesses']
|
|
expect(platformApi.getBusinesses).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('usePlatformUsers', () => {
|
|
it('fetches all platform users successfully', async () => {
|
|
const mockUsers = [
|
|
{
|
|
id: 1,
|
|
email: 'user1@test.com',
|
|
username: 'user1',
|
|
name: 'User One',
|
|
role: 'OWNER',
|
|
is_active: true,
|
|
is_staff: false,
|
|
is_superuser: false,
|
|
email_verified: true,
|
|
business: 1,
|
|
business_name: 'Business 1',
|
|
business_subdomain: 'biz1',
|
|
date_joined: '2024-01-01',
|
|
last_login: '2024-01-05',
|
|
},
|
|
{
|
|
id: 2,
|
|
email: 'user2@test.com',
|
|
username: 'user2',
|
|
role: 'STAFF',
|
|
is_active: true,
|
|
is_staff: false,
|
|
is_superuser: false,
|
|
email_verified: false,
|
|
business: null,
|
|
date_joined: '2024-01-02',
|
|
},
|
|
];
|
|
|
|
vi.mocked(platformApi.getUsers).mockResolvedValue(mockUsers);
|
|
|
|
const { result } = renderHook(() => usePlatformUsers(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(platformApi.getUsers).toHaveBeenCalledOnce();
|
|
expect(result.current.data).toEqual(mockUsers);
|
|
expect(result.current.data).toHaveLength(2);
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
const mockError = new Error('Unauthorized');
|
|
vi.mocked(platformApi.getUsers).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => usePlatformUsers(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
describe('useBusinessUsers', () => {
|
|
it('fetches users for specific business when businessId is provided', async () => {
|
|
const mockUsers = [
|
|
{
|
|
id: 1,
|
|
email: 'owner@business.com',
|
|
username: 'owner',
|
|
role: 'OWNER',
|
|
is_active: true,
|
|
is_staff: false,
|
|
is_superuser: false,
|
|
email_verified: true,
|
|
business: 5,
|
|
business_name: 'Test Business',
|
|
business_subdomain: 'test',
|
|
date_joined: '2024-01-01',
|
|
},
|
|
];
|
|
|
|
vi.mocked(platformApi.getBusinessUsers).mockResolvedValue(mockUsers);
|
|
|
|
const { result } = renderHook(() => useBusinessUsers(5), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(platformApi.getBusinessUsers).toHaveBeenCalledWith(5);
|
|
expect(result.current.data).toEqual(mockUsers);
|
|
});
|
|
|
|
it('does not fetch when businessId is null', async () => {
|
|
const { result } = renderHook(() => useBusinessUsers(null), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(platformApi.getBusinessUsers).not.toHaveBeenCalled();
|
|
expect(result.current.data).toBeUndefined();
|
|
});
|
|
|
|
it('does not fetch when businessId is 0', async () => {
|
|
const { result } = renderHook(() => useBusinessUsers(0), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(platformApi.getBusinessUsers).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
const mockError = new Error('Business not found');
|
|
vi.mocked(platformApi.getBusinessUsers).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useBusinessUsers(999), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Business Mutation Hooks
|
|
// ============================================================================
|
|
|
|
describe('useUpdateBusiness', () => {
|
|
it('updates business and invalidates cache', async () => {
|
|
const mockUpdatedBusiness = {
|
|
id: 1,
|
|
name: 'Updated Business',
|
|
subdomain: 'updated',
|
|
tier: 'ENTERPRISE',
|
|
is_active: true,
|
|
created_on: '2024-01-01',
|
|
user_count: 10,
|
|
owner: null,
|
|
max_users: 50,
|
|
max_resources: 100,
|
|
can_manage_oauth_credentials: true,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: true,
|
|
can_white_label: true,
|
|
can_api_access: true,
|
|
};
|
|
|
|
vi.mocked(platformApi.updateBusiness).mockResolvedValue(mockUpdatedBusiness);
|
|
|
|
const wrapper = createWrapper();
|
|
const { result } = renderHook(() => useUpdateBusiness(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
businessId: 1,
|
|
data: {
|
|
name: 'Updated Business',
|
|
subscription_tier: 'ENTERPRISE',
|
|
can_manage_oauth_credentials: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(platformApi.updateBusiness).toHaveBeenCalledWith(1, {
|
|
name: 'Updated Business',
|
|
subscription_tier: 'ENTERPRISE',
|
|
can_manage_oauth_credentials: true,
|
|
});
|
|
});
|
|
|
|
it('handles update error', async () => {
|
|
const mockError = new Error('Validation failed');
|
|
vi.mocked(platformApi.updateBusiness).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useUpdateBusiness(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let thrownError;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({
|
|
businessId: 1,
|
|
data: { name: '' },
|
|
});
|
|
} catch (error) {
|
|
thrownError = error;
|
|
}
|
|
});
|
|
|
|
expect(thrownError).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
describe('useCreateBusiness', () => {
|
|
it('creates business and invalidates cache', async () => {
|
|
const mockNewBusiness = {
|
|
id: 3,
|
|
name: 'New Business',
|
|
subdomain: 'newbiz',
|
|
tier: 'STARTER',
|
|
is_active: true,
|
|
created_on: '2024-01-10',
|
|
user_count: 0,
|
|
owner: null,
|
|
max_users: 5,
|
|
max_resources: 10,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: false,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
};
|
|
|
|
vi.mocked(platformApi.createBusiness).mockResolvedValue(mockNewBusiness);
|
|
|
|
const { result } = renderHook(() => useCreateBusiness(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
name: 'New Business',
|
|
subdomain: 'newbiz',
|
|
subscription_tier: 'STARTER',
|
|
});
|
|
});
|
|
|
|
expect(platformApi.createBusiness).toHaveBeenCalledWith({
|
|
name: 'New Business',
|
|
subdomain: 'newbiz',
|
|
subscription_tier: 'STARTER',
|
|
});
|
|
});
|
|
|
|
it('creates business with owner details', async () => {
|
|
const mockNewBusiness = {
|
|
id: 4,
|
|
name: 'Business with Owner',
|
|
subdomain: 'withowner',
|
|
tier: 'PROFESSIONAL',
|
|
is_active: true,
|
|
created_on: '2024-01-10',
|
|
user_count: 1,
|
|
owner: {
|
|
id: 20,
|
|
username: 'newowner',
|
|
full_name: 'New Owner',
|
|
email: 'owner@new.com',
|
|
role: 'OWNER',
|
|
email_verified: false,
|
|
},
|
|
max_users: 10,
|
|
max_resources: 20,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
};
|
|
|
|
vi.mocked(platformApi.createBusiness).mockResolvedValue(mockNewBusiness);
|
|
|
|
const { result } = renderHook(() => useCreateBusiness(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
name: 'Business with Owner',
|
|
subdomain: 'withowner',
|
|
subscription_tier: 'PROFESSIONAL',
|
|
owner_email: 'owner@new.com',
|
|
owner_name: 'New Owner',
|
|
owner_password: 'securepass123',
|
|
});
|
|
});
|
|
|
|
expect(platformApi.createBusiness).toHaveBeenCalledWith({
|
|
name: 'Business with Owner',
|
|
subdomain: 'withowner',
|
|
subscription_tier: 'PROFESSIONAL',
|
|
owner_email: 'owner@new.com',
|
|
owner_name: 'New Owner',
|
|
owner_password: 'securepass123',
|
|
});
|
|
});
|
|
|
|
it('handles creation error', async () => {
|
|
const mockError = new Error('Subdomain already exists');
|
|
vi.mocked(platformApi.createBusiness).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useCreateBusiness(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let thrownError;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({
|
|
name: 'Duplicate',
|
|
subdomain: 'duplicate',
|
|
});
|
|
} catch (error) {
|
|
thrownError = error;
|
|
}
|
|
});
|
|
|
|
expect(thrownError).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
describe('useDeleteBusiness', () => {
|
|
it('deletes business and invalidates cache', async () => {
|
|
vi.mocked(platformApi.deleteBusiness).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useDeleteBusiness(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(5);
|
|
});
|
|
|
|
expect(platformApi.deleteBusiness).toHaveBeenCalledWith(5);
|
|
});
|
|
|
|
it('handles deletion error', async () => {
|
|
const mockError = new Error('Cannot delete business with active users');
|
|
vi.mocked(platformApi.deleteBusiness).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useDeleteBusiness(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let thrownError;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync(1);
|
|
} catch (error) {
|
|
thrownError = error;
|
|
}
|
|
});
|
|
|
|
expect(thrownError).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Tenant Invitation Query Hooks
|
|
// ============================================================================
|
|
|
|
describe('useTenantInvitations', () => {
|
|
it('fetches all tenant invitations successfully', async () => {
|
|
const mockInvitations = [
|
|
{
|
|
id: 1,
|
|
email: 'invited1@test.com',
|
|
token: 'token123',
|
|
status: 'PENDING' as const,
|
|
suggested_business_name: 'Suggested Biz 1',
|
|
subscription_tier: 'PROFESSIONAL' as const,
|
|
custom_max_users: null,
|
|
custom_max_resources: null,
|
|
permissions: {
|
|
can_accept_payments: true,
|
|
},
|
|
personal_message: 'Welcome!',
|
|
invited_by: 1,
|
|
invited_by_email: 'admin@platform.com',
|
|
created_at: '2024-01-01',
|
|
expires_at: '2024-01-08',
|
|
accepted_at: null,
|
|
created_tenant: null,
|
|
created_tenant_name: null,
|
|
created_user: null,
|
|
created_user_email: null,
|
|
},
|
|
{
|
|
id: 2,
|
|
email: 'accepted@test.com',
|
|
token: 'token456',
|
|
status: 'ACCEPTED' as const,
|
|
suggested_business_name: 'Accepted Biz',
|
|
subscription_tier: 'STARTER' as const,
|
|
custom_max_users: 10,
|
|
custom_max_resources: 20,
|
|
permissions: {},
|
|
personal_message: '',
|
|
invited_by: 1,
|
|
invited_by_email: 'admin@platform.com',
|
|
created_at: '2024-01-01',
|
|
expires_at: '2024-01-08',
|
|
accepted_at: '2024-01-02',
|
|
created_tenant: 5,
|
|
created_tenant_name: 'Accepted Business',
|
|
created_user: 10,
|
|
created_user_email: 'accepted@test.com',
|
|
},
|
|
];
|
|
|
|
vi.mocked(platformApi.getTenantInvitations).mockResolvedValue(mockInvitations);
|
|
|
|
const { result } = renderHook(() => useTenantInvitations(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(platformApi.getTenantInvitations).toHaveBeenCalledOnce();
|
|
expect(result.current.data).toEqual(mockInvitations);
|
|
expect(result.current.data).toHaveLength(2);
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
const mockError = new Error('Failed to fetch invitations');
|
|
vi.mocked(platformApi.getTenantInvitations).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useTenantInvitations(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
expect(result.current.error).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
describe('useInvitationByToken', () => {
|
|
it('fetches invitation details when token is provided', async () => {
|
|
const mockInvitation = {
|
|
email: 'invited@test.com',
|
|
suggested_business_name: 'Test Business',
|
|
subscription_tier: 'PROFESSIONAL',
|
|
effective_max_users: 10,
|
|
effective_max_resources: 20,
|
|
permissions: {
|
|
can_accept_payments: true,
|
|
can_manage_oauth_credentials: false,
|
|
},
|
|
expires_at: '2024-12-31',
|
|
};
|
|
|
|
vi.mocked(platformApi.getInvitationByToken).mockResolvedValue(mockInvitation);
|
|
|
|
const { result } = renderHook(() => useInvitationByToken('valid-token-123'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isSuccess).toBe(true);
|
|
});
|
|
|
|
expect(platformApi.getInvitationByToken).toHaveBeenCalledWith('valid-token-123');
|
|
expect(result.current.data).toEqual(mockInvitation);
|
|
});
|
|
|
|
it('does not fetch when token is null', async () => {
|
|
const { result } = renderHook(() => useInvitationByToken(null), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(platformApi.getInvitationByToken).not.toHaveBeenCalled();
|
|
expect(result.current.data).toBeUndefined();
|
|
});
|
|
|
|
it('does not fetch when token is empty string', async () => {
|
|
const { result } = renderHook(() => useInvitationByToken(''), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(platformApi.getInvitationByToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not retry on error (expired/invalid token)', async () => {
|
|
const mockError = new Error('Invitation expired');
|
|
vi.mocked(platformApi.getInvitationByToken).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useInvitationByToken('expired-token'), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isError).toBe(true);
|
|
});
|
|
|
|
// Should only be called once (no retries)
|
|
expect(platformApi.getInvitationByToken).toHaveBeenCalledTimes(1);
|
|
expect(result.current.error).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Tenant Invitation Mutation Hooks
|
|
// ============================================================================
|
|
|
|
describe('useCreateTenantInvitation', () => {
|
|
it('creates invitation and invalidates cache', async () => {
|
|
const mockInvitation = {
|
|
id: 10,
|
|
email: 'new@invite.com',
|
|
token: 'new-token',
|
|
status: 'PENDING' as const,
|
|
suggested_business_name: 'New Business',
|
|
subscription_tier: 'STARTER' as const,
|
|
custom_max_users: null,
|
|
custom_max_resources: null,
|
|
permissions: {},
|
|
personal_message: 'Join us!',
|
|
invited_by: 1,
|
|
invited_by_email: 'admin@platform.com',
|
|
created_at: '2024-01-10',
|
|
expires_at: '2024-01-17',
|
|
accepted_at: null,
|
|
created_tenant: null,
|
|
created_tenant_name: null,
|
|
created_user: null,
|
|
created_user_email: null,
|
|
};
|
|
|
|
vi.mocked(platformApi.createTenantInvitation).mockResolvedValue(mockInvitation);
|
|
|
|
const { result } = renderHook(() => useCreateTenantInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
email: 'new@invite.com',
|
|
subscription_tier: 'STARTER',
|
|
suggested_business_name: 'New Business',
|
|
personal_message: 'Join us!',
|
|
});
|
|
});
|
|
|
|
expect(platformApi.createTenantInvitation).toHaveBeenCalledWith({
|
|
email: 'new@invite.com',
|
|
subscription_tier: 'STARTER',
|
|
suggested_business_name: 'New Business',
|
|
personal_message: 'Join us!',
|
|
});
|
|
});
|
|
|
|
it('creates invitation with custom limits and permissions', async () => {
|
|
const mockInvitation = {
|
|
id: 11,
|
|
email: 'custom@invite.com',
|
|
token: 'custom-token',
|
|
status: 'PENDING' as const,
|
|
suggested_business_name: 'Custom Biz',
|
|
subscription_tier: 'ENTERPRISE' as const,
|
|
custom_max_users: 100,
|
|
custom_max_resources: 200,
|
|
permissions: {
|
|
can_manage_oauth_credentials: true,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: true,
|
|
},
|
|
personal_message: '',
|
|
invited_by: 1,
|
|
invited_by_email: 'admin@platform.com',
|
|
created_at: '2024-01-10',
|
|
expires_at: '2024-01-17',
|
|
accepted_at: null,
|
|
created_tenant: null,
|
|
created_tenant_name: null,
|
|
created_user: null,
|
|
created_user_email: null,
|
|
};
|
|
|
|
vi.mocked(platformApi.createTenantInvitation).mockResolvedValue(mockInvitation);
|
|
|
|
const { result } = renderHook(() => useCreateTenantInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
email: 'custom@invite.com',
|
|
subscription_tier: 'ENTERPRISE',
|
|
custom_max_users: 100,
|
|
custom_max_resources: 200,
|
|
permissions: {
|
|
can_manage_oauth_credentials: true,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(platformApi.createTenantInvitation).toHaveBeenCalledWith({
|
|
email: 'custom@invite.com',
|
|
subscription_tier: 'ENTERPRISE',
|
|
custom_max_users: 100,
|
|
custom_max_resources: 200,
|
|
permissions: {
|
|
can_manage_oauth_credentials: true,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('handles creation error', async () => {
|
|
const mockError = new Error('Email already invited');
|
|
vi.mocked(platformApi.createTenantInvitation).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useCreateTenantInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let thrownError;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({
|
|
email: 'duplicate@test.com',
|
|
subscription_tier: 'FREE',
|
|
});
|
|
} catch (error) {
|
|
thrownError = error;
|
|
}
|
|
});
|
|
|
|
expect(thrownError).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
describe('useResendTenantInvitation', () => {
|
|
it('resends invitation and invalidates cache', async () => {
|
|
vi.mocked(platformApi.resendTenantInvitation).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useResendTenantInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(5);
|
|
});
|
|
|
|
expect(platformApi.resendTenantInvitation).toHaveBeenCalledWith(5);
|
|
});
|
|
|
|
it('handles resend error', async () => {
|
|
const mockError = new Error('Invitation already accepted');
|
|
vi.mocked(platformApi.resendTenantInvitation).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useResendTenantInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let thrownError;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync(10);
|
|
} catch (error) {
|
|
thrownError = error;
|
|
}
|
|
});
|
|
|
|
expect(thrownError).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
describe('useCancelTenantInvitation', () => {
|
|
it('cancels invitation and invalidates cache', async () => {
|
|
vi.mocked(platformApi.cancelTenantInvitation).mockResolvedValue(undefined);
|
|
|
|
const { result } = renderHook(() => useCancelTenantInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(3);
|
|
});
|
|
|
|
expect(platformApi.cancelTenantInvitation).toHaveBeenCalledWith(3);
|
|
});
|
|
|
|
it('handles cancel error', async () => {
|
|
const mockError = new Error('Invitation not found');
|
|
vi.mocked(platformApi.cancelTenantInvitation).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useCancelTenantInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let thrownError;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync(999);
|
|
} catch (error) {
|
|
thrownError = error;
|
|
}
|
|
});
|
|
|
|
expect(thrownError).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
describe('useAcceptInvitation', () => {
|
|
it('accepts invitation successfully', async () => {
|
|
const mockResponse = {
|
|
detail: 'Invitation accepted successfully',
|
|
};
|
|
|
|
vi.mocked(platformApi.acceptInvitation).mockResolvedValue(mockResponse);
|
|
|
|
const { result } = renderHook(() => useAcceptInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
token: 'valid-token',
|
|
data: {
|
|
email: 'user@test.com',
|
|
password: 'securepass123',
|
|
first_name: 'John',
|
|
last_name: 'Doe',
|
|
business_name: 'My Business',
|
|
subdomain: 'mybiz',
|
|
contact_email: 'contact@mybiz.com',
|
|
phone: '+1234567890',
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(platformApi.acceptInvitation).toHaveBeenCalledWith('valid-token', {
|
|
email: 'user@test.com',
|
|
password: 'securepass123',
|
|
first_name: 'John',
|
|
last_name: 'Doe',
|
|
business_name: 'My Business',
|
|
subdomain: 'mybiz',
|
|
contact_email: 'contact@mybiz.com',
|
|
phone: '+1234567890',
|
|
});
|
|
});
|
|
|
|
it('accepts invitation with minimal data', async () => {
|
|
const mockResponse = {
|
|
detail: 'Invitation accepted',
|
|
};
|
|
|
|
vi.mocked(platformApi.acceptInvitation).mockResolvedValue(mockResponse);
|
|
|
|
const { result } = renderHook(() => useAcceptInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
token: 'minimal-token',
|
|
data: {
|
|
email: 'user@test.com',
|
|
password: 'pass123',
|
|
first_name: 'Jane',
|
|
last_name: 'Smith',
|
|
business_name: 'Jane Biz',
|
|
subdomain: 'janebiz',
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(platformApi.acceptInvitation).toHaveBeenCalledWith('minimal-token', {
|
|
email: 'user@test.com',
|
|
password: 'pass123',
|
|
first_name: 'Jane',
|
|
last_name: 'Smith',
|
|
business_name: 'Jane Biz',
|
|
subdomain: 'janebiz',
|
|
});
|
|
});
|
|
|
|
it('handles acceptance error', async () => {
|
|
const mockError = new Error('Subdomain already taken');
|
|
vi.mocked(platformApi.acceptInvitation).mockRejectedValue(mockError);
|
|
|
|
const { result } = renderHook(() => useAcceptInvitation(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
let thrownError;
|
|
await act(async () => {
|
|
try {
|
|
await result.current.mutateAsync({
|
|
token: 'token',
|
|
data: {
|
|
email: 'user@test.com',
|
|
password: 'pass',
|
|
first_name: 'Test',
|
|
last_name: 'User',
|
|
business_name: 'Test',
|
|
subdomain: 'taken',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
thrownError = error;
|
|
}
|
|
});
|
|
|
|
expect(thrownError).toBe(mockError);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Cache Invalidation Tests
|
|
// ============================================================================
|
|
|
|
describe('Cache invalidation', () => {
|
|
it('useUpdateBusiness invalidates businesses query', async () => {
|
|
vi.mocked(platformApi.updateBusiness).mockResolvedValue({
|
|
id: 1,
|
|
name: 'Updated',
|
|
subdomain: 'updated',
|
|
tier: 'FREE',
|
|
is_active: true,
|
|
created_on: '2024-01-01',
|
|
user_count: 0,
|
|
owner: null,
|
|
max_users: 5,
|
|
max_resources: 10,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: false,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
});
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useUpdateBusiness(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
businessId: 1,
|
|
data: { name: 'Updated' },
|
|
});
|
|
});
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: ['platform', 'businesses'] });
|
|
});
|
|
|
|
it('useCreateBusiness invalidates businesses query', async () => {
|
|
vi.mocked(platformApi.createBusiness).mockResolvedValue({
|
|
id: 1,
|
|
name: 'New',
|
|
subdomain: 'new',
|
|
tier: 'FREE',
|
|
is_active: true,
|
|
created_on: '2024-01-01',
|
|
user_count: 0,
|
|
owner: null,
|
|
max_users: 5,
|
|
max_resources: 10,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: false,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
});
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useCreateBusiness(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
name: 'New',
|
|
subdomain: 'new',
|
|
});
|
|
});
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: ['platform', 'businesses'] });
|
|
});
|
|
|
|
it('useDeleteBusiness invalidates businesses query', async () => {
|
|
vi.mocked(platformApi.deleteBusiness).mockResolvedValue(undefined);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useDeleteBusiness(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(1);
|
|
});
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: ['platform', 'businesses'] });
|
|
});
|
|
|
|
it('useCreateTenantInvitation invalidates invitations query', async () => {
|
|
vi.mocked(platformApi.createTenantInvitation).mockResolvedValue({
|
|
id: 1,
|
|
email: 'test@test.com',
|
|
token: 'token',
|
|
status: 'PENDING',
|
|
suggested_business_name: 'Test',
|
|
subscription_tier: 'FREE',
|
|
custom_max_users: null,
|
|
custom_max_resources: null,
|
|
permissions: {},
|
|
personal_message: '',
|
|
invited_by: 1,
|
|
invited_by_email: 'admin@test.com',
|
|
created_at: '2024-01-01',
|
|
expires_at: '2024-01-08',
|
|
accepted_at: null,
|
|
created_tenant: null,
|
|
created_tenant_name: null,
|
|
created_user: null,
|
|
created_user_email: null,
|
|
});
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useCreateTenantInvitation(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync({
|
|
email: 'test@test.com',
|
|
subscription_tier: 'FREE',
|
|
});
|
|
});
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: ['platform', 'tenant-invitations'] });
|
|
});
|
|
|
|
it('useResendTenantInvitation invalidates invitations query', async () => {
|
|
vi.mocked(platformApi.resendTenantInvitation).mockResolvedValue(undefined);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useResendTenantInvitation(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(1);
|
|
});
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: ['platform', 'tenant-invitations'] });
|
|
});
|
|
|
|
it('useCancelTenantInvitation invalidates invitations query', async () => {
|
|
vi.mocked(platformApi.cancelTenantInvitation).mockResolvedValue(undefined);
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
|
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
|
|
const { result } = renderHook(() => useCancelTenantInvitation(), { wrapper });
|
|
|
|
await act(async () => {
|
|
await result.current.mutateAsync(1);
|
|
});
|
|
|
|
expect(spy).toHaveBeenCalledWith({ queryKey: ['platform', 'tenant-invitations'] });
|
|
});
|
|
});
|
|
});
|