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:
poduck
2025-12-08 02:36:46 -05:00
parent c220612214
commit 8dc2248f1f
145 changed files with 77947 additions and 1048 deletions

View File

@@ -0,0 +1,769 @@
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(),
patch: vi.fn(),
},
}));
import {
useApiTokens,
useCreateApiToken,
useRevokeApiToken,
useUpdateApiToken,
useTestTokensForDocs,
API_SCOPES,
SCOPE_PRESETS,
} from '../useApiTokens';
import type {
APIToken,
APITokenCreateResponse,
CreateTokenData,
TestTokenForDocs,
APIScope,
} from '../useApiTokens';
import apiClient from '../../api/client';
// Create a wrapper with QueryClientProvider
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
);
};
};
// Mock data
const mockApiToken: APIToken = {
id: 'token-123',
name: 'Test Token',
key_prefix: 'ss_test',
scopes: ['services:read', 'bookings:write'],
is_active: true,
is_sandbox: false,
created_at: '2024-01-01T00:00:00Z',
last_used_at: '2024-01-15T12:30:00Z',
expires_at: null,
created_by: {
id: 1,
username: 'testuser',
full_name: 'Test User',
},
};
const mockApiTokenCreateResponse: APITokenCreateResponse = {
...mockApiToken,
key: 'ss_test_1234567890abcdef',
};
const mockTestToken: TestTokenForDocs = {
id: 'test-token-123',
name: 'Test Token for Docs',
key_prefix: 'ss_test',
created_at: '2024-01-01T00:00:00Z',
};
describe('useApiTokens hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useApiTokens', () => {
it('fetches API tokens successfully', async () => {
const mockTokens = [mockApiToken];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTokens });
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockTokens);
expect(apiClient.get).toHaveBeenCalledWith('/v1/tokens/');
});
it('handles empty token list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch error', async () => {
const mockError = new Error('Failed to fetch tokens');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('returns multiple tokens correctly', async () => {
const mockTokens = [
mockApiToken,
{
...mockApiToken,
id: 'token-456',
name: 'Production Token',
is_sandbox: false,
},
{
...mockApiToken,
id: 'token-789',
name: 'Sandbox Token',
is_sandbox: true,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTokens });
const { result } = renderHook(() => useApiTokens(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(3);
expect(result.current.data).toEqual(mockTokens);
});
});
describe('useCreateApiToken', () => {
it('creates API token successfully', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: mockApiTokenCreateResponse });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Test Token',
scopes: ['services:read', 'bookings:write'],
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(apiClient.post).toHaveBeenCalledWith('/v1/tokens/', createData);
expect(response).toEqual(mockApiTokenCreateResponse);
expect(response?.key).toBe('ss_test_1234567890abcdef');
});
it('creates token with expiration date', async () => {
const expiresAt = '2024-12-31T23:59:59Z';
const tokenWithExpiry = {
...mockApiTokenCreateResponse,
expires_at: expiresAt,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: tokenWithExpiry });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Expiring Token',
scopes: ['services:read'],
expires_at: expiresAt,
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(apiClient.post).toHaveBeenCalledWith('/v1/tokens/', createData);
expect(response?.expires_at).toBe(expiresAt);
});
it('creates sandbox token', async () => {
const sandboxToken = {
...mockApiTokenCreateResponse,
is_sandbox: true,
key_prefix: 'ss_test',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: sandboxToken });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Sandbox Token',
scopes: ['services:read'],
is_sandbox: true,
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(apiClient.post).toHaveBeenCalledWith('/v1/tokens/', createData);
expect(response?.is_sandbox).toBe(true);
});
it('invalidates token list after successful creation', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: mockApiTokenCreateResponse });
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockApiToken] });
const wrapper = createWrapper();
const { result: tokenListResult } = renderHook(() => useApiTokens(), { wrapper });
const { result: createResult } = renderHook(() => useCreateApiToken(), { wrapper });
// Wait for initial fetch
await waitFor(() => {
expect(tokenListResult.current.isSuccess).toBe(true);
});
const initialCallCount = vi.mocked(apiClient.get).mock.calls.length;
// Create new token
await act(async () => {
await createResult.current.mutateAsync({
name: 'New Token',
scopes: ['services:read'],
});
});
// Wait for refetch
await waitFor(() => {
expect(vi.mocked(apiClient.get).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('handles creation error', async () => {
const mockError = new Error('Failed to create token');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({
name: 'Test Token',
scopes: ['services:read'],
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('creates token with all available scopes', async () => {
const allScopesToken = {
...mockApiTokenCreateResponse,
scopes: API_SCOPES.map(s => s.value),
};
vi.mocked(apiClient.post).mockResolvedValue({ data: allScopesToken });
const { result } = renderHook(() => useCreateApiToken(), {
wrapper: createWrapper(),
});
const createData: CreateTokenData = {
name: 'Full Access Token',
scopes: API_SCOPES.map(s => s.value),
};
let response: APITokenCreateResponse | undefined;
await act(async () => {
response = await result.current.mutateAsync(createData);
});
expect(response?.scopes).toHaveLength(API_SCOPES.length);
});
});
describe('useRevokeApiToken', () => {
it('revokes API token successfully', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useRevokeApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('token-123');
});
expect(apiClient.delete).toHaveBeenCalledWith('/v1/tokens/token-123/');
});
it('invalidates token list after successful revocation', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockApiToken] });
const wrapper = createWrapper();
const { result: tokenListResult } = renderHook(() => useApiTokens(), { wrapper });
const { result: revokeResult } = renderHook(() => useRevokeApiToken(), { wrapper });
// Wait for initial fetch
await waitFor(() => {
expect(tokenListResult.current.isSuccess).toBe(true);
});
const initialCallCount = vi.mocked(apiClient.get).mock.calls.length;
// Revoke token
await act(async () => {
await revokeResult.current.mutateAsync('token-123');
});
// Wait for refetch
await waitFor(() => {
expect(vi.mocked(apiClient.get).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('handles revocation error', async () => {
const mockError = new Error('Failed to revoke token');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
const { result } = renderHook(() => useRevokeApiToken(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('token-123');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
describe('useUpdateApiToken', () => {
it('updates API token successfully', async () => {
const updatedToken = {
...mockApiToken,
name: 'Updated Token Name',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
let response: APIToken | undefined;
await act(async () => {
response = await result.current.mutateAsync({
tokenId: 'token-123',
data: { name: 'Updated Token Name' },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
name: 'Updated Token Name',
});
expect(response?.name).toBe('Updated Token Name');
});
it('updates token scopes', async () => {
const updatedToken = {
...mockApiToken,
scopes: ['services:read', 'bookings:read', 'customers:read'],
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
tokenId: 'token-123',
data: { scopes: ['services:read', 'bookings:read', 'customers:read'] },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
scopes: ['services:read', 'bookings:read', 'customers:read'],
});
});
it('deactivates token', async () => {
const deactivatedToken = {
...mockApiToken,
is_active: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: deactivatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
let response: APIToken | undefined;
await act(async () => {
response = await result.current.mutateAsync({
tokenId: 'token-123',
data: { is_active: false },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
is_active: false,
});
expect(response?.is_active).toBe(false);
});
it('updates token expiration', async () => {
const newExpiry = '2025-12-31T23:59:59Z';
const updatedToken = {
...mockApiToken,
expires_at: newExpiry,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
tokenId: 'token-123',
data: { expires_at: newExpiry },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
expires_at: newExpiry,
});
});
it('invalidates token list after successful update', async () => {
const updatedToken = { ...mockApiToken, name: 'Updated' };
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockApiToken] });
const wrapper = createWrapper();
const { result: tokenListResult } = renderHook(() => useApiTokens(), { wrapper });
const { result: updateResult } = renderHook(() => useUpdateApiToken(), { wrapper });
// Wait for initial fetch
await waitFor(() => {
expect(tokenListResult.current.isSuccess).toBe(true);
});
const initialCallCount = vi.mocked(apiClient.get).mock.calls.length;
// Update token
await act(async () => {
await updateResult.current.mutateAsync({
tokenId: 'token-123',
data: { name: 'Updated' },
});
});
// Wait for refetch
await waitFor(() => {
expect(vi.mocked(apiClient.get).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('handles update error', async () => {
const mockError = new Error('Failed to update token');
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({
tokenId: 'token-123',
data: { name: 'Updated' },
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('updates multiple fields at once', async () => {
const updatedToken = {
...mockApiToken,
name: 'Updated Token',
scopes: ['services:read', 'bookings:read'],
expires_at: '2025-12-31T23:59:59Z',
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedToken });
const { result } = renderHook(() => useUpdateApiToken(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
tokenId: 'token-123',
data: {
name: 'Updated Token',
scopes: ['services:read', 'bookings:read'],
expires_at: '2025-12-31T23:59:59Z',
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/v1/tokens/token-123/', {
name: 'Updated Token',
scopes: ['services:read', 'bookings:read'],
expires_at: '2025-12-31T23:59:59Z',
});
});
});
describe('useTestTokensForDocs', () => {
it('fetches test tokens successfully', async () => {
const mockTestTokens = [mockTestToken];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTestTokens });
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockTestTokens);
expect(apiClient.get).toHaveBeenCalledWith('/v1/tokens/test-tokens/');
});
it('handles empty test token list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch error', async () => {
const mockError = new Error('Failed to fetch test tokens');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('returns multiple test tokens', async () => {
const mockTestTokens = [
mockTestToken,
{
...mockTestToken,
id: 'test-token-456',
name: 'Another Test Token',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTestTokens });
const { result } = renderHook(() => useTestTokensForDocs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(2);
});
it('uses staleTime for caching', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [mockTestToken] });
const wrapper = createWrapper();
const { result: result1 } = renderHook(() => useTestTokensForDocs(), { wrapper });
await waitFor(() => {
expect(result1.current.isSuccess).toBe(true);
});
// Render hook again - should use cached data
const { result: result2 } = renderHook(() => useTestTokensForDocs(), { wrapper });
expect(result2.current.data).toEqual([mockTestToken]);
// Should only call API once due to staleTime cache
expect(vi.mocked(apiClient.get).mock.calls.length).toBe(1);
});
});
describe('API_SCOPES constant', () => {
it('contains expected scopes', () => {
expect(API_SCOPES).toBeDefined();
expect(Array.isArray(API_SCOPES)).toBe(true);
expect(API_SCOPES.length).toBeGreaterThan(0);
});
it('has correct structure for each scope', () => {
API_SCOPES.forEach((scope: APIScope) => {
expect(scope).toHaveProperty('value');
expect(scope).toHaveProperty('label');
expect(scope).toHaveProperty('description');
expect(typeof scope.value).toBe('string');
expect(typeof scope.label).toBe('string');
expect(typeof scope.description).toBe('string');
});
});
it('contains essential scopes', () => {
const scopeValues = API_SCOPES.map(s => s.value);
expect(scopeValues).toContain('services:read');
expect(scopeValues).toContain('bookings:read');
expect(scopeValues).toContain('bookings:write');
expect(scopeValues).toContain('customers:read');
expect(scopeValues).toContain('customers:write');
});
});
describe('SCOPE_PRESETS constant', () => {
it('contains expected presets', () => {
expect(SCOPE_PRESETS).toBeDefined();
expect(SCOPE_PRESETS).toHaveProperty('booking_widget');
expect(SCOPE_PRESETS).toHaveProperty('read_only');
expect(SCOPE_PRESETS).toHaveProperty('full_access');
});
it('booking_widget preset has correct structure', () => {
const preset = SCOPE_PRESETS.booking_widget;
expect(preset).toHaveProperty('label');
expect(preset).toHaveProperty('description');
expect(preset).toHaveProperty('scopes');
expect(Array.isArray(preset.scopes)).toBe(true);
expect(preset.scopes).toContain('services:read');
expect(preset.scopes).toContain('bookings:write');
});
it('read_only preset contains only read scopes', () => {
const preset = SCOPE_PRESETS.read_only;
expect(preset.scopes.every(scope => scope.includes(':read'))).toBe(true);
});
it('full_access preset contains all scopes', () => {
const preset = SCOPE_PRESETS.full_access;
expect(preset.scopes).toHaveLength(API_SCOPES.length);
expect(preset.scopes).toEqual(API_SCOPES.map(s => s.value));
});
});
describe('TypeScript types', () => {
it('APIToken type includes all required fields', () => {
const token: APIToken = mockApiToken;
expect(token.id).toBeDefined();
expect(token.name).toBeDefined();
expect(token.key_prefix).toBeDefined();
expect(token.scopes).toBeDefined();
expect(token.is_active).toBeDefined();
expect(token.is_sandbox).toBeDefined();
expect(token.created_at).toBeDefined();
});
it('APITokenCreateResponse extends APIToken with key', () => {
const createResponse: APITokenCreateResponse = mockApiTokenCreateResponse;
expect(createResponse.key).toBeDefined();
expect(createResponse.id).toBeDefined();
expect(createResponse.name).toBeDefined();
});
it('CreateTokenData has correct structure', () => {
const createData: CreateTokenData = {
name: 'Test',
scopes: ['services:read'],
};
expect(createData.name).toBe('Test');
expect(createData.scopes).toEqual(['services:read']);
});
it('TestTokenForDocs has minimal fields', () => {
const testToken: TestTokenForDocs = mockTestToken;
expect(testToken.id).toBeDefined();
expect(testToken.name).toBeDefined();
expect(testToken.key_prefix).toBeDefined();
expect(testToken.created_at).toBeDefined();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,637 @@
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 dependencies
vi.mock('../../api/auth', () => ({
login: vi.fn(),
logout: vi.fn(),
getCurrentUser: vi.fn(),
masquerade: vi.fn(),
stopMasquerade: vi.fn(),
}));
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
setCookie: vi.fn(),
deleteCookie: vi.fn(),
}));
vi.mock('../../utils/domain', () => ({
getBaseDomain: vi.fn(() => 'lvh.me'),
buildSubdomainUrl: vi.fn((subdomain, path) => `http://${subdomain}.lvh.me:5173${path || '/'}`),
}));
import {
useAuth,
useCurrentUser,
useLogin,
useLogout,
useIsAuthenticated,
useMasquerade,
useStopMasquerade,
} from '../useAuth';
import * as authApi from '../../api/auth';
import * as cookies from '../../utils/cookies';
// Create a wrapper with QueryClientProvider
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
);
};
};
describe('useAuth hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('useAuth', () => {
it('provides setTokens function', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
expect(result.current.setTokens).toBeDefined();
expect(typeof result.current.setTokens).toBe('function');
});
it('setTokens calls setCookie for both tokens', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
result.current.setTokens('access-123', 'refresh-456');
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-123', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-456', 7);
});
});
describe('useCurrentUser', () => {
it('returns null when no token exists', async () => {
vi.mocked(cookies.getCookie).mockReturnValue(null);
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(authApi.getCurrentUser).not.toHaveBeenCalled();
});
it('fetches user when token exists', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
role: 'owner',
first_name: 'Test',
last_name: 'User',
};
vi.mocked(cookies.getCookie).mockReturnValue('valid-token');
vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser as authApi.User);
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockUser);
expect(authApi.getCurrentUser).toHaveBeenCalled();
});
it('returns null when getCurrentUser fails', async () => {
vi.mocked(cookies.getCookie).mockReturnValue('invalid-token');
vi.mocked(authApi.getCurrentUser).mockRejectedValue(new Error('Unauthorized'));
const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeNull();
});
});
describe('useLogin', () => {
it('stores tokens in cookies on success', async () => {
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 1,
email: 'test@example.com',
role: 'owner',
first_name: 'Test',
last_name: 'User',
},
};
vi.mocked(authApi.login).mockResolvedValue(mockResponse as authApi.LoginResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
email: 'test@example.com',
password: 'password123',
});
});
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-token', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-token', 7);
});
it('clears masquerade stack on login', async () => {
window.localStorage.setItem('masquerade_stack', JSON.stringify([{ user_pk: 1 }]));
const mockResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 1,
email: 'test@example.com',
role: 'owner',
},
};
vi.mocked(authApi.login).mockResolvedValue(mockResponse as authApi.LoginResponse);
const { result } = renderHook(() => useLogin(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
email: 'test@example.com',
password: 'password123',
});
});
// After login, masquerade_stack should be removed
expect(window.localStorage.getItem('masquerade_stack')).toBeFalsy();
});
});
describe('useLogout', () => {
it('clears tokens and masquerade stack', async () => {
window.localStorage.setItem('masquerade_stack', JSON.stringify([{ user_pk: 1 }]));
vi.mocked(authApi.logout).mockResolvedValue(undefined);
// Mock window.location
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
value: { ...originalLocation, href: '', protocol: 'http:', port: '5173' },
writable: true,
});
const { result } = renderHook(() => useLogout(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(cookies.deleteCookie).toHaveBeenCalledWith('access_token');
expect(cookies.deleteCookie).toHaveBeenCalledWith('refresh_token');
// After logout, masquerade_stack should be removed
expect(window.localStorage.getItem('masquerade_stack')).toBeFalsy();
// Restore window.location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
});
});
describe('useIsAuthenticated', () => {
it('returns false when no user', async () => {
vi.mocked(cookies.getCookie).mockReturnValue(null);
const { result } = renderHook(() => useIsAuthenticated(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('returns true when user exists', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
role: 'owner',
};
vi.mocked(cookies.getCookie).mockReturnValue('valid-token');
vi.mocked(authApi.getCurrentUser).mockResolvedValue(mockUser as authApi.User);
const { result } = renderHook(() => useIsAuthenticated(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current).toBe(true);
});
});
});
describe('useMasquerade', () => {
let originalLocation: Location;
let originalFetch: typeof fetch;
beforeEach(() => {
originalLocation = window.location;
originalFetch = global.fetch;
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
href: 'http://platform.lvh.me:5173/',
hostname: 'platform.lvh.me',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
// Mock fetch for logout API call
global.fetch = vi.fn().mockResolvedValue({ ok: true });
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
global.fetch = originalFetch;
});
it('calls masquerade API with user_pk and current stack', async () => {
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 2,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(authApi.masquerade).toHaveBeenCalledWith(2, []);
});
it('passes existing masquerade stack to API', async () => {
const existingStack = [{ user_pk: 1, access: 'old-access', refresh: 'old-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 3,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: [...existingStack, { user_pk: 2, access: 'mid-access', refresh: 'mid-refresh' }],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(3);
});
expect(authApi.masquerade).toHaveBeenCalledWith(3, existingStack);
});
it('stores masquerade stack in localStorage on success', async () => {
const mockStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 2,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: mockStack,
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(localStorage.getItem('masquerade_stack')).toEqual(JSON.stringify(mockStack));
});
it('redirects to platform subdomain for platform users', async () => {
// Set current hostname to something else to trigger redirect
Object.defineProperty(window, 'location', {
value: {
...window.location,
hostname: 'demo.lvh.me', // Different from platform
href: 'http://demo.lvh.me:5173/',
},
writable: true,
});
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 1,
email: 'admin@example.com',
role: 'superuser',
},
masquerade_stack: [],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(1);
});
// Should have called fetch to clear session
expect(global.fetch).toHaveBeenCalled();
});
it('sets cookies when no redirect is needed', async () => {
// Set current hostname to match the target
Object.defineProperty(window, 'location', {
value: {
hostname: 'demo.lvh.me',
href: 'http://demo.lvh.me:5173/',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
const mockResponse = {
access: 'new-access-token',
refresh: 'new-refresh-token',
user: {
id: 2,
email: 'staff@example.com',
role: 'staff',
business_subdomain: 'demo',
},
masquerade_stack: [],
};
vi.mocked(authApi.masquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'new-access-token', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'new-refresh-token', 7);
});
});
describe('useStopMasquerade', () => {
let originalLocation: Location;
let originalFetch: typeof fetch;
beforeEach(() => {
originalLocation = window.location;
originalFetch = global.fetch;
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
href: 'http://demo.lvh.me:5173/',
hostname: 'demo.lvh.me',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
global.fetch = vi.fn().mockResolvedValue({ ok: true });
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
global.fetch = originalFetch;
});
it('throws error when no masquerade stack exists', async () => {
localStorage.removeItem('masquerade_stack');
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
let error: Error | undefined;
await act(async () => {
try {
await result.current.mutateAsync();
} catch (e) {
error = e as Error;
}
});
expect(error?.message).toBe('No masquerading session to stop');
});
it('calls stopMasquerade API with current stack', async () => {
const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
const mockResponse = {
access: 'restored-access-token',
refresh: 'restored-refresh-token',
user: {
id: 1,
email: 'admin@example.com',
role: 'superuser',
},
masquerade_stack: [],
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(authApi.stopMasquerade).toHaveBeenCalledWith(existingStack);
});
it('clears masquerade stack when returning to original user', async () => {
const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
const mockResponse = {
access: 'restored-access-token',
refresh: 'restored-refresh-token',
user: {
id: 1,
email: 'admin@example.com',
role: 'superuser',
},
masquerade_stack: [], // Empty stack means back to original
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(localStorage.getItem('masquerade_stack')).toBeNull();
});
it('keeps stack when still masquerading after stop', async () => {
const deepStack = [
{ user_pk: 1, access: 'level1-access', refresh: 'level1-refresh' },
{ user_pk: 2, access: 'level2-access', refresh: 'level2-refresh' },
];
localStorage.setItem('masquerade_stack', JSON.stringify(deepStack));
const remainingStack = [{ user_pk: 1, access: 'level1-access', refresh: 'level1-refresh' }];
const mockResponse = {
access: 'level2-access-token',
refresh: 'level2-refresh-token',
user: {
id: 2,
email: 'manager@example.com',
role: 'manager',
business_subdomain: 'demo',
},
masquerade_stack: remainingStack,
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(localStorage.getItem('masquerade_stack')).toEqual(JSON.stringify(remainingStack));
});
it('sets cookies when no redirect is needed', async () => {
const existingStack = [{ user_pk: 1, access: 'original-access', refresh: 'original-refresh' }];
localStorage.setItem('masquerade_stack', JSON.stringify(existingStack));
// Set hostname to match target subdomain
Object.defineProperty(window, 'location', {
value: {
hostname: 'demo.lvh.me',
href: 'http://demo.lvh.me:5173/',
protocol: 'http:',
port: '5173',
reload: vi.fn(),
},
writable: true,
});
const mockResponse = {
access: 'restored-access-token',
refresh: 'restored-refresh-token',
user: {
id: 2,
email: 'owner@example.com',
role: 'owner',
business_subdomain: 'demo',
},
masquerade_stack: [],
};
vi.mocked(authApi.stopMasquerade).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useStopMasquerade(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'restored-access-token', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'restored-refresh-token', 7);
});
});
});

View File

@@ -0,0 +1,349 @@
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 dependencies
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
},
}));
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
}));
import { useCurrentBusiness, useUpdateBusiness, useBusinessUsers, useResources, useCreateResource } from '../useBusiness';
import apiClient from '../../api/client';
import { getCookie } from '../../utils/cookies';
// 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('useBusiness hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCurrentBusiness', () => {
it('returns null when no token exists', async () => {
vi.mocked(getCookie).mockReturnValue(null);
const { result } = renderHook(() => useCurrentBusiness(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(apiClient.get).not.toHaveBeenCalled();
});
it('fetches business and transforms data', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
primary_color: '#FF0000',
secondary_color: '#00FF00',
logo_url: 'https://example.com/logo.png',
timezone: 'America/Denver',
timezone_display_mode: 'business',
tier: 'professional',
status: 'active',
created_at: '2024-01-01T00:00:00Z',
payments_enabled: true,
plan_permissions: {
sms_reminders: true,
api_access: true,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => useCurrentBusiness(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/business/current/');
expect(result.current.data).toEqual(expect.objectContaining({
id: '1',
name: 'Test Business',
subdomain: 'test',
primaryColor: '#FF0000',
secondaryColor: '#00FF00',
logoUrl: 'https://example.com/logo.png',
timezone: 'America/Denver',
plan: 'professional',
paymentsEnabled: true,
}));
});
it('uses default values for missing fields', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Minimal Business',
subdomain: 'min',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => useCurrentBusiness(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.primaryColor).toBe('#3B82F6');
expect(result.current.data?.secondaryColor).toBe('#1E40AF');
expect(result.current.data?.logoDisplayMode).toBe('text-only');
expect(result.current.data?.timezone).toBe('America/New_York');
expect(result.current.data?.paymentsEnabled).toBe(false);
});
});
describe('useUpdateBusiness', () => {
it('maps frontend fields to backend fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'Updated Name',
primaryColor: '#123456',
secondaryColor: '#654321',
timezone: 'America/Los_Angeles',
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
name: 'Updated Name',
primary_color: '#123456',
secondary_color: '#654321',
timezone: 'America/Los_Angeles',
});
});
it('handles logo fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
logoUrl: 'https://new-logo.com/logo.png',
emailLogoUrl: 'https://new-logo.com/email.png',
logoDisplayMode: 'logo-only',
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
logo_url: 'https://new-logo.com/logo.png',
email_logo_url: 'https://new-logo.com/email.png',
logo_display_mode: 'logo-only',
});
});
it('handles booking-related settings', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
resourcesCanReschedule: true,
requirePaymentMethodToBook: true,
cancellationWindowHours: 24,
lateCancellationFeePercent: 50,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
resources_can_reschedule: true,
require_payment_method_to_book: true,
cancellation_window_hours: 24,
late_cancellation_fee_percent: 50,
});
});
it('handles website and dashboard content', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateBusiness(), {
wrapper: createWrapper(),
});
const websitePages = { home: { title: 'Welcome' } };
const dashboardContent = [{ type: 'text', content: 'Hello' }];
await act(async () => {
await result.current.mutateAsync({
websitePages,
customerDashboardContent: dashboardContent,
initialSetupComplete: true,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/business/current/update/', {
website_pages: websitePages,
customer_dashboard_content: dashboardContent,
initial_setup_complete: true,
});
});
});
describe('useBusinessUsers', () => {
it('fetches staff users', async () => {
const mockUsers = [
{ id: 1, name: 'Staff 1' },
{ id: 2, name: 'Staff 2' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockUsers });
const { result } = renderHook(() => useBusinessUsers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
expect(result.current.data).toEqual(mockUsers);
});
});
describe('useResources', () => {
it('fetches resources', async () => {
const mockResources = [
{ id: 1, name: 'Resource 1', type: 'equipment' },
{ id: 2, name: 'Resource 2', type: 'room' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
const { result } = renderHook(() => useResources(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/resources/');
expect(result.current.data).toEqual(mockResources);
});
it('handles empty resources list', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useResources(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch error', async () => {
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useResources(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
});
});
describe('useCreateResource', () => {
it('creates a resource', async () => {
const mockResource = { id: 3, name: 'New Resource', type: 'equipment' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResource });
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
const data = await result.current.mutateAsync({ name: 'New Resource', type: 'equipment' });
expect(data).toEqual(mockResource);
});
expect(apiClient.post).toHaveBeenCalledWith('/resources/', {
name: 'New Resource',
type: 'equipment',
});
});
it('creates a resource with user_id', async () => {
const mockResource = { id: 4, name: 'Staff Resource', type: 'staff', user_id: 'user-123' };
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResource });
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ name: 'Staff Resource', type: 'staff', user_id: 'user-123' });
});
expect(apiClient.post).toHaveBeenCalledWith('/resources/', {
name: 'Staff Resource',
type: 'staff',
user_id: 'user-123',
});
});
it('handles creation error', async () => {
vi.mocked(apiClient.post).mockRejectedValue(new Error('Validation failed'));
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await expect(
act(async () => {
await result.current.mutateAsync({ name: '', type: 'equipment' });
})
).rejects.toThrow('Validation failed');
});
});
});

View File

@@ -0,0 +1,729 @@
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 the business API
vi.mock('../../api/business', () => ({
getBusinessOAuthSettings: vi.fn(),
updateBusinessOAuthSettings: vi.fn(),
}));
import {
useBusinessOAuthSettings,
useUpdateBusinessOAuthSettings,
} from '../useBusinessOAuth';
import * as businessApi from '../../api/business';
// Create wrapper for React Query
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('useBusinessOAuth hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useBusinessOAuthSettings', () => {
it('fetches business OAuth settings successfully', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
// Wait for success
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(businessApi.getBusinessOAuthSettings).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockResponse);
expect(result.current.data?.settings.enabledProviders).toHaveLength(2);
expect(result.current.data?.availableProviders).toHaveLength(2);
});
it('handles empty enabled providers', async () => {
const mockResponse = {
settings: {
enabledProviders: [],
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toEqual([]);
expect(result.current.data?.availableProviders).toHaveLength(1);
});
it('handles custom credentials enabled', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: true,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.useCustomCredentials).toBe(true);
expect(result.current.data?.settings.autoLinkByEmail).toBe(true);
});
it('handles API error gracefully', async () => {
const mockError = new Error('Failed to fetch OAuth settings');
vi.mocked(businessApi.getBusinessOAuthSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBeUndefined();
});
it('does not retry on failure', async () => {
vi.mocked(businessApi.getBusinessOAuthSettings).mockRejectedValue(
new Error('404 Not Found')
);
const { result } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should be called only once (no retries)
expect(businessApi.getBusinessOAuthSettings).toHaveBeenCalledTimes(1);
});
it('caches data with 5 minute stale time', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result, rerender } = renderHook(() => useBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Rerender should use cached data (within stale time)
rerender();
// Should still only be called once
expect(businessApi.getBusinessOAuthSettings).toHaveBeenCalledTimes(1);
});
});
describe('useUpdateBusinessOAuthSettings', () => {
it('updates enabled providers successfully', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google', 'microsoft', 'github'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
{
id: 'github',
name: 'GitHub',
icon: 'github-icon',
description: 'Sign in with GitHub',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google', 'microsoft', 'github'],
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: ['google', 'microsoft', 'github'],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('updates allowRegistration flag', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: false,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
allowRegistration: false,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
allowRegistration: false,
});
});
it('updates autoLinkByEmail flag', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: true,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
autoLinkByEmail: true,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
autoLinkByEmail: true,
});
});
it('updates useCustomCredentials flag', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
useCustomCredentials: true,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
useCustomCredentials: true,
});
});
it('updates multiple settings at once', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: false,
autoLinkByEmail: true,
useCustomCredentials: true,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google', 'microsoft'],
allowRegistration: false,
autoLinkByEmail: true,
useCustomCredentials: true,
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: ['google', 'microsoft'],
allowRegistration: false,
autoLinkByEmail: true,
useCustomCredentials: true,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toHaveLength(2);
expect(result.current.data?.settings.allowRegistration).toBe(false);
expect(result.current.data?.settings.autoLinkByEmail).toBe(true);
expect(result.current.data?.settings.useCustomCredentials).toBe(true);
});
it('updates query cache on success', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper,
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google'],
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthSettings']);
expect(cachedData).toEqual(mockResponse);
});
it('handles update error gracefully', async () => {
const mockError = new Error('Failed to update settings');
vi.mocked(businessApi.updateBusinessOAuthSettings).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
allowRegistration: true,
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('handles partial update with only enabledProviders', async () => {
const mockResponse = {
settings: {
enabledProviders: ['github'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'github',
name: 'GitHub',
icon: 'github-icon',
description: 'Sign in with GitHub',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['github'],
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: ['github'],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toEqual(['github']);
});
it('handles empty enabled providers array', async () => {
const mockResponse = {
settings: {
enabledProviders: [],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: [],
});
});
expect(businessApi.updateBusinessOAuthSettings).toHaveBeenCalledWith({
enabledProviders: [],
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.settings.enabledProviders).toEqual([]);
});
it('preserves availableProviders from backend response', async () => {
const mockResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
{
id: 'github',
name: 'GitHub',
icon: 'github-icon',
description: 'Sign in with GitHub',
},
],
};
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
enabledProviders: ['google'],
});
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.availableProviders).toHaveLength(3);
expect(result.current.data?.availableProviders.map(p => p.id)).toEqual([
'google',
'microsoft',
'github',
]);
});
});
describe('integration tests', () => {
it('fetches settings then updates them', async () => {
const initialResponse = {
settings: {
enabledProviders: ['google'],
allowRegistration: true,
autoLinkByEmail: false,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
const updatedResponse = {
settings: {
enabledProviders: ['google', 'microsoft'],
allowRegistration: true,
autoLinkByEmail: true,
useCustomCredentials: false,
},
availableProviders: [
{
id: 'google',
name: 'Google',
icon: 'google-icon',
description: 'Sign in with Google',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: 'microsoft-icon',
description: 'Sign in with Microsoft',
},
],
};
vi.mocked(businessApi.getBusinessOAuthSettings).mockResolvedValue(initialResponse);
vi.mocked(businessApi.updateBusinessOAuthSettings).mockResolvedValue(updatedResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
// Fetch initial settings
const { result: fetchResult } = renderHook(() => useBusinessOAuthSettings(), {
wrapper,
});
await waitFor(() => {
expect(fetchResult.current.isSuccess).toBe(true);
});
expect(fetchResult.current.data?.settings.enabledProviders).toEqual(['google']);
expect(fetchResult.current.data?.settings.autoLinkByEmail).toBe(false);
// Update settings
const { result: updateResult } = renderHook(() => useUpdateBusinessOAuthSettings(), {
wrapper,
});
await act(async () => {
await updateResult.current.mutateAsync({
enabledProviders: ['google', 'microsoft'],
autoLinkByEmail: true,
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthSettings']);
expect(cachedData).toEqual(updatedResponse);
});
});
});

View File

@@ -0,0 +1,921 @@
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 the business API
vi.mock('../../api/business', () => ({
getBusinessOAuthCredentials: vi.fn(),
updateBusinessOAuthCredentials: vi.fn(),
}));
import {
useBusinessOAuthCredentials,
useUpdateBusinessOAuthCredentials,
} from '../useBusinessOAuthCredentials';
import * as businessApi from '../../api/business';
// Create wrapper for React Query
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('useBusinessOAuthCredentials hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useBusinessOAuthCredentials', () => {
it('fetches business OAuth credentials successfully', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-client-id-123',
client_secret: 'google-client-secret-456',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id-789',
client_secret: 'microsoft-client-secret-012',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
// Wait for success
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(businessApi.getBusinessOAuthCredentials).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockResponse);
expect(result.current.data?.credentials.google.client_id).toBe('google-client-id-123');
expect(result.current.data?.credentials.google.has_secret).toBe(true);
expect(result.current.data?.credentials.microsoft.client_id).toBe('microsoft-client-id-789');
expect(result.current.data?.useCustomCredentials).toBe(true);
});
it('handles empty credentials', async () => {
const mockResponse = {
credentials: {},
useCustomCredentials: false,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials).toEqual({});
expect(result.current.data?.useCustomCredentials).toBe(false);
});
it('handles credentials with has_secret false', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-client-id-123',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.has_secret).toBe(false);
expect(result.current.data?.credentials.google.client_secret).toBe('');
});
it('handles multiple providers with mixed credential states', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-client-id',
client_secret: '',
has_secret: false,
},
github: {
client_id: '',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(Object.keys(result.current.data?.credentials || {})).toHaveLength(3);
expect(result.current.data?.credentials.google.has_secret).toBe(true);
expect(result.current.data?.credentials.microsoft.has_secret).toBe(false);
expect(result.current.data?.credentials.github.has_secret).toBe(false);
});
it('handles API error gracefully', async () => {
const mockError = new Error('Failed to fetch OAuth credentials');
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBeUndefined();
});
it('does not retry on failure (404)', async () => {
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(
new Error('404 Not Found')
);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should be called only once (no retries)
expect(businessApi.getBusinessOAuthCredentials).toHaveBeenCalledTimes(1);
});
it('caches data with 5 minute stale time', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-client-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result, rerender } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Rerender should use cached data (within stale time)
rerender();
// Should still only be called once
expect(businessApi.getBusinessOAuthCredentials).toHaveBeenCalledTimes(1);
});
it('handles 401 unauthorized error', async () => {
const mockError = new Error('401 Unauthorized');
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('handles network error', async () => {
const mockError = new Error('Network Error');
vi.mocked(businessApi.getBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
});
describe('useUpdateBusinessOAuthCredentials', () => {
it('updates credentials for a single provider successfully', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'new-google-client-id',
client_secret: 'new-google-secret',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.client_id).toBe('new-google-client-id');
expect(result.current.data?.credentials.google.has_secret).toBe(true);
});
it('updates credentials for multiple providers', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-id',
client_secret: 'microsoft-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
microsoft: {
client_id: 'microsoft-id',
client_secret: 'microsoft-secret',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
microsoft: {
client_id: 'microsoft-id',
client_secret: 'microsoft-secret',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(Object.keys(result.current.data?.credentials || {})).toHaveLength(2);
});
it('updates only client_id without client_secret', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'updated-google-id',
client_secret: 'existing-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'updated-google-id',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'updated-google-id',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('updates only client_secret without client_id', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'existing-google-id',
client_secret: 'new-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_secret: 'new-secret',
},
},
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_secret: 'new-secret',
},
},
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('updates useCustomCredentials flag only', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: false,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
useCustomCredentials: false,
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
useCustomCredentials: false,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.useCustomCredentials).toBe(false);
});
it('updates both credentials and useCustomCredentials flag', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'custom-google-id',
client_secret: 'custom-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'custom-google-id',
client_secret: 'custom-google-secret',
},
},
useCustomCredentials: true,
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: 'custom-google-id',
client_secret: 'custom-google-secret',
},
},
useCustomCredentials: true,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.useCustomCredentials).toBe(true);
expect(result.current.data?.credentials.google.has_secret).toBe(true);
});
it('updates query cache on success', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper,
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
},
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect(cachedData).toEqual(mockResponse);
});
it('handles update error gracefully', async () => {
const mockError = new Error('Failed to update credentials');
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'test-id',
},
},
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('handles validation error from API', async () => {
const mockError = new Error('Invalid client_id format');
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'invalid-format',
},
},
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('handles clearing credentials by passing empty values', async () => {
const mockResponse = {
credentials: {
google: {
client_id: '',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: false,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: '',
client_secret: '',
},
},
useCustomCredentials: false,
});
});
expect(businessApi.updateBusinessOAuthCredentials).toHaveBeenCalledWith({
credentials: {
google: {
client_id: '',
client_secret: '',
},
},
useCustomCredentials: false,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.has_secret).toBe(false);
expect(result.current.data?.useCustomCredentials).toBe(false);
});
it('handles permission error (403)', async () => {
const mockError = new Error('403 Forbidden - Insufficient permissions');
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
let caughtError: any = null;
await act(async () => {
try {
await result.current.mutateAsync({
useCustomCredentials: true,
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('preserves backend response structure with has_secret flags', async () => {
const mockResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
microsoft: {
client_id: 'microsoft-id',
client_secret: '',
has_secret: false,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
},
microsoft: {
client_id: 'microsoft-id',
},
},
});
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.credentials.google.has_secret).toBe(true);
expect(result.current.data?.credentials.microsoft.has_secret).toBe(false);
expect(result.current.data?.credentials.microsoft.client_secret).toBe('');
});
});
describe('integration tests', () => {
it('fetches credentials then updates them', async () => {
const initialResponse = {
credentials: {
google: {
client_id: 'initial-google-id',
client_secret: 'initial-google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
const updatedResponse = {
credentials: {
google: {
client_id: 'updated-google-id',
client_secret: 'updated-google-secret',
has_secret: true,
},
microsoft: {
client_id: 'new-microsoft-id',
client_secret: 'new-microsoft-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(initialResponse);
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(updatedResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
// Fetch initial credentials
const { result: fetchResult } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper,
});
await waitFor(() => {
expect(fetchResult.current.isSuccess).toBe(true);
});
expect(fetchResult.current.data?.credentials.google.client_id).toBe('initial-google-id');
expect(Object.keys(fetchResult.current.data?.credentials || {})).toHaveLength(1);
// Update credentials
const { result: updateResult } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper,
});
await act(async () => {
await updateResult.current.mutateAsync({
credentials: {
google: {
client_id: 'updated-google-id',
client_secret: 'updated-google-secret',
},
microsoft: {
client_id: 'new-microsoft-id',
client_secret: 'new-microsoft-secret',
},
},
});
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect(cachedData).toEqual(updatedResponse);
expect((cachedData as any).credentials.google.client_id).toBe('updated-google-id');
expect((cachedData as any).credentials.microsoft.client_id).toBe('new-microsoft-id');
});
it('toggles custom credentials on and off', async () => {
const initialResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
const toggledOffResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: false,
};
const toggledOnResponse = {
credentials: {
google: {
client_id: 'google-id',
client_secret: 'google-secret',
has_secret: true,
},
},
useCustomCredentials: true,
};
vi.mocked(businessApi.getBusinessOAuthCredentials).mockResolvedValue(initialResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
// Fetch initial state
const { result: fetchResult } = renderHook(() => useBusinessOAuthCredentials(), {
wrapper,
});
await waitFor(() => {
expect(fetchResult.current.isSuccess).toBe(true);
});
expect(fetchResult.current.data?.useCustomCredentials).toBe(true);
// Toggle off
const { result: updateResult } = renderHook(() => useUpdateBusinessOAuthCredentials(), {
wrapper,
});
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(toggledOffResponse);
await act(async () => {
await updateResult.current.mutateAsync({
useCustomCredentials: false,
});
});
let cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect((cachedData as any).useCustomCredentials).toBe(false);
// Toggle back on
vi.mocked(businessApi.updateBusinessOAuthCredentials).mockResolvedValue(toggledOnResponse);
await act(async () => {
await updateResult.current.mutateAsync({
useCustomCredentials: true,
});
});
cachedData = queryClient.getQueryData(['businessOAuthCredentials']);
expect((cachedData as any).useCustomCredentials).toBe(true);
});
});
});

View File

@@ -0,0 +1,942 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import apiClient from '../../api/client';
import {
useCommunicationCredits,
useCreditTransactions,
useUpdateCreditsSettings,
useAddCredits,
useCreatePaymentIntent,
useConfirmPayment,
useSetupPaymentMethod,
useSavePaymentMethod,
useCommunicationUsageStats,
usePhoneNumbers,
useSearchPhoneNumbers,
usePurchasePhoneNumber,
useReleasePhoneNumber,
useChangePhoneNumber,
CommunicationCredits,
CreditTransaction,
ProxyPhoneNumber,
AvailablePhoneNumber,
} from '../useCommunicationCredits';
// Mock the API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
describe('useCommunicationCredits', () => {
let queryClient: QueryClient;
let wrapper: React.FC<{ children: React.ReactNode }>;
const mockCredits: CommunicationCredits = {
id: 1,
balance_cents: 50000,
auto_reload_enabled: true,
auto_reload_threshold_cents: 10000,
auto_reload_amount_cents: 50000,
low_balance_warning_cents: 20000,
low_balance_warning_sent: false,
stripe_payment_method_id: 'pm_test123',
last_twilio_sync_at: '2025-12-07T10:00:00Z',
total_loaded_cents: 100000,
total_spent_cents: 50000,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
const mockTransactions: CreditTransaction[] = [
{
id: 1,
amount_cents: 50000,
balance_after_cents: 50000,
transaction_type: 'manual',
description: 'Manual credit purchase',
reference_type: 'payment_intent',
reference_id: 'pi_test123',
stripe_charge_id: 'ch_test123',
created_at: '2025-12-07T10:00:00Z',
},
{
id: 2,
amount_cents: -1000,
balance_after_cents: 49000,
transaction_type: 'usage',
description: 'SMS to +15551234567',
reference_type: 'sms_message',
reference_id: 'msg_123',
stripe_charge_id: '',
created_at: '2025-12-07T11:00:00Z',
},
];
const mockPhoneNumber: ProxyPhoneNumber = {
id: 1,
phone_number: '+15551234567',
friendly_name: 'Main Office Line',
status: 'assigned',
monthly_fee_cents: 100,
capabilities: {
voice: true,
sms: true,
mms: true,
},
assigned_at: '2025-12-01T00:00:00Z',
last_billed_at: '2025-12-01T00:00:00Z',
};
const mockAvailableNumber: AvailablePhoneNumber = {
phone_number: '+15559876543',
friendly_name: '(555) 987-6543',
locality: 'New York',
region: 'NY',
postal_code: '10001',
capabilities: {
voice: true,
sms: true,
mms: true,
},
monthly_cost_cents: 100,
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
wrapper = ({ children }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
vi.clearAllMocks();
});
afterEach(() => {
queryClient.clear();
});
describe('useCommunicationCredits', () => {
it('should fetch communication credits successfully', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockCredits });
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/');
expect(result.current.data).toEqual(mockCredits);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch credits');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.data).toBeUndefined();
expect(result.current.error).toEqual(mockError);
});
it('should use correct query key', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockCredits });
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const cachedData = queryClient.getQueryData(['communicationCredits']);
expect(cachedData).toEqual(mockCredits);
});
it('should have staleTime of 30 seconds', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockCredits });
const { result } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const queryState = queryClient.getQueryState(['communicationCredits']);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useCreditTransactions', () => {
it('should fetch credit transactions with pagination', async () => {
const mockResponse = {
results: mockTransactions,
count: 2,
next: null,
previous: null,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useCreditTransactions(1, 20), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/transactions/', {
params: { page: 1, limit: 20 },
});
expect(result.current.data).toEqual(mockResponse);
});
it('should support custom page and limit', async () => {
const mockResponse = {
results: [mockTransactions[0]],
count: 10,
next: 'http://api.example.com/page=3',
previous: 'http://api.example.com/page=1',
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useCreditTransactions(2, 10), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/transactions/', {
params: { page: 2, limit: 10 },
});
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch transactions');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCreditTransactions(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useUpdateCreditsSettings', () => {
it('should update credit settings successfully', async () => {
const updatedCredits = {
...mockCredits,
auto_reload_enabled: false,
auto_reload_threshold_cents: 5000,
};
vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedCredits });
const { result } = renderHook(() => useUpdateCreditsSettings(), { wrapper });
result.current.mutate({
auto_reload_enabled: false,
auto_reload_threshold_cents: 5000,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.patch).toHaveBeenCalledWith('/communication-credits/settings/', {
auto_reload_enabled: false,
auto_reload_threshold_cents: 5000,
});
expect(result.current.data).toEqual(updatedCredits);
});
it('should update query cache on success', async () => {
const updatedCredits = { ...mockCredits, auto_reload_enabled: false };
queryClient.setQueryData(['communicationCredits'], mockCredits);
vi.mocked(apiClient.patch).mockResolvedValueOnce({ data: updatedCredits });
const { result } = renderHook(() => useUpdateCreditsSettings(), { wrapper });
result.current.mutate({ auto_reload_enabled: false });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const cachedData = queryClient.getQueryData(['communicationCredits']);
expect(cachedData).toEqual(updatedCredits);
});
it('should handle update errors', async () => {
const mockError = new Error('Failed to update settings');
vi.mocked(apiClient.patch).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useUpdateCreditsSettings(), { wrapper });
result.current.mutate({ auto_reload_enabled: false });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useAddCredits', () => {
it('should add credits successfully', async () => {
const mockResponse = {
success: true,
balance_cents: 100000,
transaction_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useAddCredits(), { wrapper });
result.current.mutate({
amount_cents: 50000,
payment_method_id: 'pm_test123',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/add/', {
amount_cents: 50000,
payment_method_id: 'pm_test123',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate credits and transactions queries on success', async () => {
const mockResponse = { success: true, balance_cents: 100000 };
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useAddCredits(), { wrapper });
result.current.mutate({ amount_cents: 50000 });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle add credits errors', async () => {
const mockError = new Error('Payment failed');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useAddCredits(), { wrapper });
result.current.mutate({ amount_cents: 50000 });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useCreatePaymentIntent', () => {
it('should create payment intent successfully', async () => {
const mockResponse = {
client_secret: 'pi_test_secret',
payment_intent_id: 'pi_test123',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useCreatePaymentIntent(), { wrapper });
result.current.mutate(50000);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/create-payment-intent/', {
amount_cents: 50000,
});
expect(result.current.data).toEqual(mockResponse);
});
it('should handle payment intent creation errors', async () => {
const mockError = new Error('Failed to create payment intent');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCreatePaymentIntent(), { wrapper });
result.current.mutate(50000);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useConfirmPayment', () => {
it('should confirm payment successfully', async () => {
const mockResponse = {
success: true,
balance_cents: 100000,
transaction_id: 123,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useConfirmPayment(), { wrapper });
result.current.mutate({
payment_intent_id: 'pi_test123',
save_payment_method: true,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/confirm-payment/', {
payment_intent_id: 'pi_test123',
save_payment_method: true,
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate credits and transactions queries on success', async () => {
const mockResponse = { success: true };
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useConfirmPayment(), { wrapper });
result.current.mutate({ payment_intent_id: 'pi_test123' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle confirmation errors', async () => {
const mockError = new Error('Payment confirmation failed');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useConfirmPayment(), { wrapper });
result.current.mutate({ payment_intent_id: 'pi_test123' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useSetupPaymentMethod', () => {
it('should setup payment method successfully', async () => {
const mockResponse = {
client_secret: 'seti_test_secret',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSetupPaymentMethod(), { wrapper });
result.current.mutate();
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/setup-payment-method/');
expect(result.current.data).toEqual(mockResponse);
});
it('should handle setup errors', async () => {
const mockError = new Error('Failed to setup payment method');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSetupPaymentMethod(), { wrapper });
result.current.mutate();
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useSavePaymentMethod', () => {
it('should save payment method successfully', async () => {
const mockResponse = {
success: true,
payment_method_id: 'pm_test123',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSavePaymentMethod(), { wrapper });
result.current.mutate('pm_test123');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/save-payment-method/', {
payment_method_id: 'pm_test123',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate credits query on success', async () => {
const mockResponse = { success: true };
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useSavePaymentMethod(), { wrapper });
result.current.mutate('pm_test123');
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
});
it('should handle save errors', async () => {
const mockError = new Error('Failed to save payment method');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSavePaymentMethod(), { wrapper });
result.current.mutate('pm_test123');
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useCommunicationUsageStats', () => {
it('should fetch usage stats successfully', async () => {
const mockStats = {
sms_sent_this_month: 150,
voice_minutes_this_month: 45.5,
proxy_numbers_active: 2,
estimated_cost_cents: 2500,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockStats });
const { result } = renderHook(() => useCommunicationUsageStats(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/usage-stats/');
expect(result.current.data).toEqual(mockStats);
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch stats');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useCommunicationUsageStats(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
it('should use correct query key', async () => {
const mockStats = {
sms_sent_this_month: 150,
voice_minutes_this_month: 45.5,
proxy_numbers_active: 2,
estimated_cost_cents: 2500,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockStats });
const { result } = renderHook(() => useCommunicationUsageStats(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const cachedData = queryClient.getQueryData(['communicationUsageStats']);
expect(cachedData).toEqual(mockStats);
});
});
describe('usePhoneNumbers', () => {
it('should fetch phone numbers successfully', async () => {
const mockResponse = {
numbers: [mockPhoneNumber],
count: 1,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => usePhoneNumbers(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/phone-numbers/');
expect(result.current.data).toEqual(mockResponse);
});
it('should handle fetch errors', async () => {
const mockError = new Error('Failed to fetch phone numbers');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => usePhoneNumbers(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useSearchPhoneNumbers', () => {
it('should search phone numbers successfully', async () => {
const mockResponse = {
numbers: [mockAvailableNumber],
count: 1,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSearchPhoneNumbers(), { wrapper });
result.current.mutate({
area_code: '555',
country: 'US',
limit: 10,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/phone-numbers/search/', {
params: {
area_code: '555',
country: 'US',
limit: 10,
},
});
expect(result.current.data).toEqual(mockResponse);
});
it('should support contains parameter', async () => {
const mockResponse = {
numbers: [mockAvailableNumber],
count: 1,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useSearchPhoneNumbers(), { wrapper });
result.current.mutate({
contains: '123',
country: 'US',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/communication-credits/phone-numbers/search/', {
params: {
contains: '123',
country: 'US',
},
});
});
it('should handle search errors', async () => {
const mockError = new Error('Search failed');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSearchPhoneNumbers(), { wrapper });
result.current.mutate({ area_code: '555' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('usePurchasePhoneNumber', () => {
it('should purchase phone number successfully', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
result.current.mutate({
phone_number: '+15551234567',
friendly_name: 'Main Office Line',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/phone-numbers/purchase/', {
phone_number: '+15551234567',
friendly_name: 'Main Office Line',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate queries on success', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
result.current.mutate({ phone_number: '+15551234567' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['phoneNumbers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle purchase errors', async () => {
const mockError = new Error('Insufficient credits');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
result.current.mutate({ phone_number: '+15551234567' });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useReleasePhoneNumber', () => {
it('should release phone number successfully', async () => {
const mockResponse = {
success: true,
message: 'Phone number released successfully',
};
vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useReleasePhoneNumber(), { wrapper });
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.delete).toHaveBeenCalledWith('/communication-credits/phone-numbers/1/');
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate queries on success', async () => {
const mockResponse = {
success: true,
message: 'Phone number released successfully',
};
vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useReleasePhoneNumber(), { wrapper });
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['phoneNumbers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationUsageStats'] });
});
it('should handle release errors', async () => {
const mockError = new Error('Failed to release phone number');
vi.mocked(apiClient.delete).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useReleasePhoneNumber(), { wrapper });
result.current.mutate(1);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('useChangePhoneNumber', () => {
it('should change phone number successfully', async () => {
const newPhoneNumber = {
...mockPhoneNumber,
phone_number: '+15559876543',
friendly_name: 'Updated Office Line',
};
const mockResponse = {
success: true,
phone_number: newPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
friendly_name: 'Updated Office Line',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/phone-numbers/1/change/', {
new_phone_number: '+15559876543',
friendly_name: 'Updated Office Line',
});
expect(result.current.data).toEqual(mockResponse);
});
it('should invalidate queries on success', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['phoneNumbers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['communicationCredits'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creditTransactions'] });
});
it('should handle change errors', async () => {
const mockError = new Error('Failed to change phone number');
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
it('should exclude numberId from request body', async () => {
const mockResponse = {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
};
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: mockResponse });
const { result } = renderHook(() => useChangePhoneNumber(), { wrapper });
result.current.mutate({
numberId: 1,
new_phone_number: '+15559876543',
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Verify numberId is NOT in the request body
expect(apiClient.post).toHaveBeenCalledWith('/communication-credits/phone-numbers/1/change/', {
new_phone_number: '+15559876543',
});
});
});
describe('Integration Tests', () => {
it('should update credits after adding credits', async () => {
const initialCredits = mockCredits;
const updatedCredits = { ...mockCredits, balance_cents: 100000 };
// Initial fetch
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: initialCredits });
const { result: creditsResult } = renderHook(() => useCommunicationCredits(), { wrapper });
await waitFor(() => expect(creditsResult.current.isSuccess).toBe(true));
expect(creditsResult.current.data?.balance_cents).toBe(50000);
// Add credits
vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } });
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: updatedCredits });
const { result: addResult } = renderHook(() => useAddCredits(), { wrapper });
addResult.current.mutate({ amount_cents: 50000 });
await waitFor(() => expect(addResult.current.isSuccess).toBe(true));
// Refetch credits
await creditsResult.current.refetch();
expect(creditsResult.current.data?.balance_cents).toBe(100000);
});
it('should update phone numbers list after purchasing', async () => {
const initialResponse = { numbers: [], count: 0 };
const updatedResponse = { numbers: [mockPhoneNumber], count: 1 };
// Initial fetch
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: initialResponse });
const { result: numbersResult } = renderHook(() => usePhoneNumbers(), { wrapper });
await waitFor(() => expect(numbersResult.current.isSuccess).toBe(true));
expect(numbersResult.current.data?.count).toBe(0);
// Purchase number
vi.mocked(apiClient.post).mockResolvedValueOnce({
data: {
success: true,
phone_number: mockPhoneNumber,
balance_cents: 49900,
},
});
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: updatedResponse });
const { result: purchaseResult } = renderHook(() => usePurchasePhoneNumber(), { wrapper });
purchaseResult.current.mutate({ phone_number: '+15551234567' });
await waitFor(() => expect(purchaseResult.current.isSuccess).toBe(true));
// Refetch numbers
await numbersResult.current.refetch();
expect(numbersResult.current.data?.count).toBe(1);
expect(numbersResult.current.data?.numbers[0]).toEqual(mockPhoneNumber);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,664 @@
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 the customDomains API
vi.mock('../../api/customDomains', () => ({
getCustomDomains: vi.fn(),
addCustomDomain: vi.fn(),
deleteCustomDomain: vi.fn(),
verifyCustomDomain: vi.fn(),
setPrimaryDomain: vi.fn(),
}));
import {
useCustomDomains,
useAddCustomDomain,
useDeleteCustomDomain,
useVerifyCustomDomain,
useSetPrimaryDomain,
} from '../useCustomDomains';
import * as customDomainsApi from '../../api/customDomains';
// Create wrapper with fresh QueryClient for each test
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);
};
};
// Mock data
const mockCustomDomain = {
id: 1,
domain: 'example.com',
is_verified: true,
ssl_provisioned: true,
is_primary: true,
verification_token: 'abc123',
dns_txt_record: 'smoothschedule-verify=abc123',
dns_txt_record_name: '_smoothschedule',
created_at: '2024-01-01T00:00:00Z',
verified_at: '2024-01-01T12:00:00Z',
};
const mockUnverifiedDomain = {
id: 2,
domain: 'test.com',
is_verified: false,
ssl_provisioned: false,
is_primary: false,
verification_token: 'xyz789',
dns_txt_record: 'smoothschedule-verify=xyz789',
dns_txt_record_name: '_smoothschedule',
created_at: '2024-01-02T00:00:00Z',
};
const mockCustomDomains = [mockCustomDomain, mockUnverifiedDomain];
describe('useCustomDomains hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================
// Query Hook - useCustomDomains
// ============================================
describe('useCustomDomains', () => {
it('fetches all custom domains successfully', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockCustomDomains);
expect(result.current.data).toHaveLength(2);
});
it('returns empty array when no domains exist', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue([]);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
expect(result.current.data).toHaveLength(0);
});
it('handles fetch errors without retrying', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockRejectedValue(
new Error('Failed to fetch domains')
);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Failed to fetch domains'));
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1); // No retry
});
it('uses staleTime of 5 minutes', async () => {
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles 404 errors gracefully', async () => {
const notFoundError = new Error('Not found');
vi.mocked(customDomainsApi.getCustomDomains).mockRejectedValue(notFoundError);
const { result } = renderHook(() => useCustomDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(notFoundError);
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1);
});
});
// ============================================
// Mutation Hook - useAddCustomDomain
// ============================================
describe('useAddCustomDomain', () => {
it('adds a new custom domain successfully', async () => {
const newDomain = { ...mockUnverifiedDomain, domain: 'newdomain.com' };
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(newDomain);
const { result } = renderHook(() => useAddCustomDomain(), {
wrapper: createWrapper(),
});
let addedDomain;
await act(async () => {
addedDomain = await result.current.mutateAsync('newdomain.com');
});
expect(customDomainsApi.addCustomDomain).toHaveBeenCalledWith(
'newdomain.com',
expect.anything()
);
expect(addedDomain).toEqual(newDomain);
});
it('invalidates customDomains query on success', async () => {
const newDomain = { ...mockUnverifiedDomain, domain: 'another.com' };
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([...mockCustomDomains, newDomain]);
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(newDomain);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
expect(domainsResult.current.data).toHaveLength(2);
// Add a new domain
const { result: addResult } = renderHook(() => useAddCustomDomain(), {
wrapper,
});
await act(async () => {
await addResult.current.mutateAsync('another.com');
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles add domain errors', async () => {
vi.mocked(customDomainsApi.addCustomDomain).mockRejectedValue(
new Error('Domain already exists')
);
const { result } = renderHook(() => useAddCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('example.com');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Domain already exists'));
});
it('handles domain with uppercase and whitespace', async () => {
const newDomain = { ...mockUnverifiedDomain, domain: 'test.com' };
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(newDomain);
const { result } = renderHook(() => useAddCustomDomain(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(' TEST.COM ');
});
// API should normalize the domain
expect(customDomainsApi.addCustomDomain).toHaveBeenCalledWith(
' TEST.COM ',
expect.anything()
);
});
});
// ============================================
// Mutation Hook - useDeleteCustomDomain
// ============================================
describe('useDeleteCustomDomain', () => {
it('deletes a custom domain successfully', async () => {
vi.mocked(customDomainsApi.deleteCustomDomain).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteCustomDomain(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(customDomainsApi.deleteCustomDomain).toHaveBeenCalledWith(2, expect.anything());
});
it('invalidates customDomains query on success', async () => {
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([mockCustomDomain]); // After delete
vi.mocked(customDomainsApi.deleteCustomDomain).mockResolvedValue(undefined);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
expect(domainsResult.current.data).toHaveLength(2);
// Delete a domain
const { result: deleteResult } = renderHook(() => useDeleteCustomDomain(), {
wrapper,
});
await act(async () => {
await deleteResult.current.mutateAsync(2);
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles delete domain errors', async () => {
vi.mocked(customDomainsApi.deleteCustomDomain).mockRejectedValue(
new Error('Cannot delete primary domain')
);
const { result } = renderHook(() => useDeleteCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(1);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Cannot delete primary domain'));
});
it('handles 404 errors for non-existent domains', async () => {
vi.mocked(customDomainsApi.deleteCustomDomain).mockRejectedValue(
new Error('Domain not found')
);
const { result } = renderHook(() => useDeleteCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(999);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Domain not found'));
});
});
// ============================================
// Mutation Hook - useVerifyCustomDomain
// ============================================
describe('useVerifyCustomDomain', () => {
it('verifies a custom domain successfully', async () => {
const verifyResponse = { verified: true, message: 'Domain verified successfully' };
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const { result } = renderHook(() => useVerifyCustomDomain(), {
wrapper: createWrapper(),
});
let verifyResult;
await act(async () => {
verifyResult = await result.current.mutateAsync(2);
});
expect(customDomainsApi.verifyCustomDomain).toHaveBeenCalledWith(2, expect.anything());
expect(verifyResult).toEqual(verifyResponse);
});
it('returns failure when verification fails', async () => {
const verifyResponse = {
verified: false,
message: 'TXT record not found. Please add the DNS record and try again.',
};
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const { result } = renderHook(() => useVerifyCustomDomain(), {
wrapper: createWrapper(),
});
let verifyResult;
await act(async () => {
verifyResult = await result.current.mutateAsync(2);
});
expect(verifyResult).toEqual(verifyResponse);
expect(verifyResult?.verified).toBe(false);
});
it('invalidates customDomains query on success', async () => {
const verifyResponse = { verified: true, message: 'Domain verified' };
const verifiedDomain = { ...mockUnverifiedDomain, is_verified: true };
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([mockCustomDomain, verifiedDomain]);
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Verify a domain
const { result: verifyResult } = renderHook(() => useVerifyCustomDomain(), {
wrapper,
});
await act(async () => {
await verifyResult.current.mutateAsync(2);
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles verification errors', async () => {
vi.mocked(customDomainsApi.verifyCustomDomain).mockRejectedValue(
new Error('Verification service unavailable')
);
const { result } = renderHook(() => useVerifyCustomDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(2);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Verification service unavailable'));
});
it('invalidates even on failed verification (not error)', async () => {
const verifyResponse = { verified: false, message: 'TXT record not found' };
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue(verifyResponse);
const wrapper = createWrapper();
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
const { result: verifyResult } = renderHook(() => useVerifyCustomDomain(), {
wrapper,
});
await act(async () => {
await verifyResult.current.mutateAsync(2);
});
// Should still invalidate even though verified=false
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
});
// ============================================
// Mutation Hook - useSetPrimaryDomain
// ============================================
describe('useSetPrimaryDomain', () => {
it('sets a domain as primary successfully', async () => {
const updatedDomain = { ...mockUnverifiedDomain, is_primary: true };
vi.mocked(customDomainsApi.setPrimaryDomain).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useSetPrimaryDomain(), {
wrapper: createWrapper(),
});
let primaryDomain;
await act(async () => {
primaryDomain = await result.current.mutateAsync(2);
});
expect(customDomainsApi.setPrimaryDomain).toHaveBeenCalledWith(2, expect.anything());
expect(primaryDomain).toEqual(updatedDomain);
expect(primaryDomain?.is_primary).toBe(true);
});
it('invalidates customDomains query on success', async () => {
const updatedPrimaryDomain = { ...mockUnverifiedDomain, is_primary: true };
const oldPrimaryDomain = { ...mockCustomDomain, is_primary: false };
vi.mocked(customDomainsApi.getCustomDomains)
.mockResolvedValueOnce(mockCustomDomains)
.mockResolvedValueOnce([oldPrimaryDomain, updatedPrimaryDomain]);
vi.mocked(customDomainsApi.setPrimaryDomain).mockResolvedValue(updatedPrimaryDomain);
const wrapper = createWrapper();
// First fetch custom domains
const { result: domainsResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Set new primary domain
const { result: setPrimaryResult } = renderHook(() => useSetPrimaryDomain(), {
wrapper,
});
await act(async () => {
await setPrimaryResult.current.mutateAsync(2);
});
// Verify customDomains was invalidated and refetched
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
});
it('handles set primary domain errors', async () => {
vi.mocked(customDomainsApi.setPrimaryDomain).mockRejectedValue(
new Error('Domain must be verified before setting as primary')
);
const { result } = renderHook(() => useSetPrimaryDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(2);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(
new Error('Domain must be verified before setting as primary')
);
});
it('handles non-existent domain errors', async () => {
vi.mocked(customDomainsApi.setPrimaryDomain).mockRejectedValue(
new Error('Domain not found')
);
const { result } = renderHook(() => useSetPrimaryDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(999);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Domain not found'));
});
});
// ============================================
// Integration Tests
// ============================================
describe('Integration - Query invalidation', () => {
it('all mutations invalidate the customDomains query', async () => {
const wrapper = createWrapper();
vi.mocked(customDomainsApi.getCustomDomains).mockResolvedValue(mockCustomDomains);
vi.mocked(customDomainsApi.addCustomDomain).mockResolvedValue(mockUnverifiedDomain);
vi.mocked(customDomainsApi.deleteCustomDomain).mockResolvedValue(undefined);
vi.mocked(customDomainsApi.verifyCustomDomain).mockResolvedValue({
verified: true,
message: 'Success',
});
vi.mocked(customDomainsApi.setPrimaryDomain).mockResolvedValue(mockCustomDomain);
// Initial fetch
const { result: queryResult } = renderHook(() => useCustomDomains(), {
wrapper,
});
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(1);
// Test add mutation
const { result: addResult } = renderHook(() => useAddCustomDomain(), {
wrapper,
});
await act(async () => {
await addResult.current.mutateAsync('new.com');
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(2);
});
// Test delete mutation
const { result: deleteResult } = renderHook(() => useDeleteCustomDomain(), {
wrapper,
});
await act(async () => {
await deleteResult.current.mutateAsync(1);
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(3);
});
// Test verify mutation
const { result: verifyResult } = renderHook(() => useVerifyCustomDomain(), {
wrapper,
});
await act(async () => {
await verifyResult.current.mutateAsync(2);
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(4);
});
// Test setPrimary mutation
const { result: setPrimaryResult } = renderHook(() => useSetPrimaryDomain(), {
wrapper,
});
await act(async () => {
await setPrimaryResult.current.mutateAsync(2);
});
await waitFor(() => {
expect(customDomainsApi.getCustomDomains).toHaveBeenCalledTimes(5);
});
});
});
});

View File

@@ -0,0 +1,687 @@
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 the API client
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
import {
useCustomerBilling,
useCustomerPaymentMethods,
useCreateSetupIntent,
useDeletePaymentMethod,
useSetDefaultPaymentMethod,
} from '../useCustomerBilling';
import apiClient from '../../api/client';
// Create wrapper with fresh QueryClient for each test
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('useCustomerBilling hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCustomerBilling', () => {
it('fetches customer billing data successfully', async () => {
const mockBillingData = {
outstanding: [
{
id: 1,
title: 'Haircut Appointment',
service_name: 'Basic Haircut',
amount: 5000,
amount_display: '$50.00',
status: 'confirmed',
start_time: '2025-12-08T10:00:00Z',
end_time: '2025-12-08T10:30:00Z',
payment_status: 'unpaid' as const,
payment_intent_id: null,
},
{
id: 2,
title: 'Massage Session',
service_name: 'Deep Tissue Massage',
amount: 8000,
amount_display: '$80.00',
status: 'confirmed',
start_time: '2025-12-09T14:00:00Z',
end_time: '2025-12-09T15:00:00Z',
payment_status: 'pending' as const,
payment_intent_id: 'pi_123456',
},
],
payment_history: [
{
id: 1,
event_id: 100,
event_title: 'Haircut - John Doe',
service_name: 'Premium Haircut',
amount: 7500,
amount_display: '$75.00',
currency: 'usd',
status: 'succeeded',
payment_intent_id: 'pi_completed_123',
created_at: '2025-12-01T10:00:00Z',
completed_at: '2025-12-01T10:05:00Z',
event_date: '2025-12-01T14:00:00Z',
},
{
id: 2,
event_id: 101,
event_title: 'Spa Treatment',
service_name: 'Facial Treatment',
amount: 12000,
amount_display: '$120.00',
currency: 'usd',
status: 'succeeded',
payment_intent_id: 'pi_completed_456',
created_at: '2025-11-28T09:00:00Z',
completed_at: '2025-11-28T09:02:00Z',
event_date: '2025-11-28T15:30:00Z',
},
],
summary: {
total_spent: 19500,
total_spent_display: '$195.00',
total_outstanding: 13000,
total_outstanding_display: '$130.00',
payment_count: 2,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBillingData } as any);
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/payments/customer/billing/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockBillingData);
});
it('handles empty billing data', async () => {
const mockEmptyData = {
outstanding: [],
payment_history: [],
summary: {
total_spent: 0,
total_spent_display: '$0.00',
total_outstanding: 0,
total_outstanding_display: '$0.00',
payment_count: 0,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockEmptyData } as any);
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.outstanding).toHaveLength(0);
expect(result.current.data?.payment_history).toHaveLength(0);
expect(result.current.data?.summary.payment_count).toBe(0);
});
it('handles API errors gracefully', async () => {
const mockError = new Error('Failed to fetch billing data');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('uses 30 second staleTime', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { outstanding: [], payment_history: [], summary: {} } } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => useCustomerBilling(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['customerBilling']);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['customerBilling']);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
it('does not retry on failure', async () => {
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useCustomerBilling(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should only be called once (no retries)
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
});
describe('useCustomerPaymentMethods', () => {
it('fetches payment methods successfully', async () => {
const mockPaymentMethods = {
payment_methods: [
{
id: 'pm_123456',
type: 'card',
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2025,
is_default: true,
},
{
id: 'pm_789012',
type: 'card',
brand: 'mastercard',
last4: '5555',
exp_month: 6,
exp_year: 2026,
is_default: false,
},
],
has_stripe_customer: true,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPaymentMethods } as any);
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/payments/customer/payment-methods/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockPaymentMethods);
expect(result.current.data?.payment_methods).toHaveLength(2);
});
it('handles no payment methods', async () => {
const mockNoPaymentMethods = {
payment_methods: [],
has_stripe_customer: false,
message: 'No payment methods found',
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockNoPaymentMethods } as any);
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.payment_methods).toHaveLength(0);
expect(result.current.data?.has_stripe_customer).toBe(false);
expect(result.current.data?.message).toBe('No payment methods found');
});
it('handles API errors gracefully', async () => {
const mockError = new Error('Failed to fetch payment methods');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('uses 60 second staleTime', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: { payment_methods: [], has_stripe_customer: false } } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => useCustomerPaymentMethods(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['customerPaymentMethods']);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['customerPaymentMethods']);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
it('does not retry on failure', async () => {
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useCustomerPaymentMethods(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should only be called once (no retries)
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
});
describe('useCreateSetupIntent', () => {
it('creates setup intent successfully', async () => {
const mockSetupIntent = {
client_secret: 'seti_123_secret_456',
setup_intent_id: 'seti_123456',
customer_id: 'cus_789012',
stripe_account: '',
publishable_key: 'pk_test_123456',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetupIntent } as any);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync();
expect(response).toEqual(mockSetupIntent);
});
expect(apiClient.post).toHaveBeenCalledWith('/payments/customer/setup-intent/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
it('creates setup intent with connected account', async () => {
const mockSetupIntent = {
client_secret: 'seti_123_secret_789',
setup_intent_id: 'seti_789012',
customer_id: 'cus_345678',
stripe_account: 'acct_connect_123',
publishable_key: undefined,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetupIntent } as any);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
let response;
await act(async () => {
response = await result.current.mutateAsync();
});
expect(response).toEqual(mockSetupIntent);
expect(response.stripe_account).toBe('acct_connect_123');
});
it('handles setup intent creation errors', async () => {
const mockError = new Error('Failed to create setup intent');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync();
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
});
it('tracks mutation loading state', async () => {
vi.mocked(apiClient.post).mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: { client_secret: 'seti_test' } }), 50)
)
);
const { result } = renderHook(() => useCreateSetupIntent(), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(false);
const promise = act(async () => {
await result.current.mutateAsync();
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await promise;
});
});
describe('useDeletePaymentMethod', () => {
it('deletes payment method successfully', async () => {
const mockDeleteResponse = {
success: true,
message: 'Payment method deleted successfully',
};
vi.mocked(apiClient.delete).mockResolvedValue({ data: mockDeleteResponse } as any);
const { result } = renderHook(() => useDeletePaymentMethod(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync('pm_123456');
expect(response).toEqual(mockDeleteResponse);
});
expect(apiClient.delete).toHaveBeenCalledWith('/payments/customer/payment-methods/pm_123456/');
expect(apiClient.delete).toHaveBeenCalledTimes(1);
});
it('invalidates payment methods query on success', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({
data: { success: true, message: 'Deleted' },
} as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeletePaymentMethod(), { wrapper });
await act(async () => {
await result.current.mutateAsync('pm_123456');
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['customerPaymentMethods'] });
});
it('handles delete errors gracefully', async () => {
const mockError = new Error('Cannot delete default payment method');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
const { result } = renderHook(() => useDeletePaymentMethod(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('pm_123456');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
});
it('does not invalidate queries on error', async () => {
vi.mocked(apiClient.delete).mockRejectedValue(new Error('Delete failed'));
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeletePaymentMethod(), { wrapper });
await act(async () => {
try {
await result.current.mutateAsync('pm_123456');
} catch {
// Expected to fail
}
});
expect(invalidateQueriesSpy).not.toHaveBeenCalled();
});
it('handles multiple payment method deletions', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({
data: { success: true, message: 'Deleted' },
} as any);
const { result } = renderHook(() => useDeletePaymentMethod(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('pm_111111');
});
await act(async () => {
await result.current.mutateAsync('pm_222222');
});
expect(apiClient.delete).toHaveBeenCalledTimes(2);
expect(apiClient.delete).toHaveBeenNthCalledWith(1, '/payments/customer/payment-methods/pm_111111/');
expect(apiClient.delete).toHaveBeenNthCalledWith(2, '/payments/customer/payment-methods/pm_222222/');
});
});
describe('useSetDefaultPaymentMethod', () => {
it('sets default payment method successfully', async () => {
const mockSetDefaultResponse = {
success: true,
message: 'Default payment method updated',
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockSetDefaultResponse } as any);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync('pm_789012');
expect(response).toEqual(mockSetDefaultResponse);
});
expect(apiClient.post).toHaveBeenCalledWith('/payments/customer/payment-methods/pm_789012/default/');
expect(apiClient.post).toHaveBeenCalledTimes(1);
});
it('invalidates payment methods query on success', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { success: true, message: 'Updated' },
} as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), { wrapper });
await act(async () => {
await result.current.mutateAsync('pm_789012');
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['customerPaymentMethods'] });
});
it('handles set default errors gracefully', async () => {
const mockError = new Error('Payment method not found');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('pm_invalid');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
});
it('does not invalidate queries on error', async () => {
vi.mocked(apiClient.post).mockRejectedValue(new Error('Update failed'));
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), { wrapper });
await act(async () => {
try {
await result.current.mutateAsync('pm_123456');
} catch {
// Expected to fail
}
});
expect(invalidateQueriesSpy).not.toHaveBeenCalled();
});
it('handles switching default between payment methods', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { success: true, message: 'Updated' },
} as any);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
// Set first method as default
await act(async () => {
await result.current.mutateAsync('pm_111111');
});
// Switch to second method as default
await act(async () => {
await result.current.mutateAsync('pm_222222');
});
expect(apiClient.post).toHaveBeenCalledTimes(2);
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/payments/customer/payment-methods/pm_111111/default/');
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/payments/customer/payment-methods/pm_222222/default/');
});
it('tracks mutation loading state', async () => {
vi.mocked(apiClient.post).mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: { success: true, message: 'Updated' } }), 50)
)
);
const { result } = renderHook(() => useSetDefaultPaymentMethod(), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(false);
const promise = act(async () => {
await result.current.mutateAsync('pm_123456');
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
await promise;
});
});
});

View File

@@ -0,0 +1,224 @@
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(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
useCustomers,
useCreateCustomer,
useUpdateCustomer,
useDeleteCustomer,
} from '../useCustomers';
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('useCustomers hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useCustomers', () => {
it('fetches customers and transforms data', async () => {
const mockCustomers = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone: '555-1234',
total_spend: '150.00',
status: 'Active',
user_id: 10,
},
{
id: 2,
user: { name: 'Jane Smith', email: 'jane@example.com' },
phone: '',
total_spend: '0',
status: 'Inactive',
user: 20,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCustomers });
const { result } = renderHook(() => useCustomers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/customers/?');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual(expect.objectContaining({
id: '1',
name: 'John Doe',
email: 'john@example.com',
totalSpend: 150,
status: 'Active',
}));
});
it('applies status filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useCustomers({ status: 'Active' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/customers/?status=Active');
});
});
it('applies search filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useCustomers({ search: 'john' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/customers/?search=john');
});
});
it('applies multiple filters', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useCustomers({ status: 'Blocked', search: 'test' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/customers/?status=Blocked&search=test');
});
});
it('handles customers with last_visit date', async () => {
const mockCustomers = [
{
id: 1,
name: 'Customer',
email: 'c@example.com',
total_spend: '0',
last_visit: '2024-01-15T10:00:00Z',
user_id: 1,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockCustomers });
const { result } = renderHook(() => useCustomers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].lastVisit).toBeInstanceOf(Date);
});
});
describe('useCreateCustomer', () => {
it('creates customer with field mapping', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateCustomer(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
userId: '5',
phone: '555-9999',
city: 'Denver',
state: 'CO',
zip: '80202',
status: 'Active',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/customers/', {
user: 5,
phone: '555-9999',
city: 'Denver',
state: 'CO',
zip: '80202',
status: 'Active',
avatar_url: undefined,
tags: undefined,
});
});
});
describe('useUpdateCustomer', () => {
it('updates customer with mapped fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateCustomer(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: {
phone: '555-0000',
status: 'Blocked',
tags: ['vip'],
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/customers/1/', {
phone: '555-0000',
city: undefined,
state: undefined,
zip: undefined,
status: 'Blocked',
avatar_url: undefined,
tags: ['vip'],
});
});
});
describe('useDeleteCustomer', () => {
it('deletes customer by id', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteCustomer(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('7');
});
expect(apiClient.delete).toHaveBeenCalledWith('/customers/7/');
});
});
});

View File

@@ -0,0 +1,958 @@
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 the domains API
vi.mock('../../api/domains', () => ({
searchDomains: vi.fn(),
getDomainPrices: vi.fn(),
registerDomain: vi.fn(),
getRegisteredDomains: vi.fn(),
getDomainRegistration: vi.fn(),
updateNameservers: vi.fn(),
toggleAutoRenew: vi.fn(),
renewDomain: vi.fn(),
syncDomain: vi.fn(),
getSearchHistory: vi.fn(),
}));
import {
useDomainSearch,
useDomainPrices,
useRegisterDomain,
useRegisteredDomains,
useDomainRegistration,
useUpdateNameservers,
useToggleAutoRenew,
useRenewDomain,
useSyncDomain,
useSearchHistory,
} from '../useDomains';
import * as domainsApi from '../../api/domains';
// Create wrapper with fresh QueryClient for each test
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);
};
};
// Mock data
const mockDomainAvailability = [
{
domain: 'example.com',
available: true,
price: 12.99,
premium: false,
premium_price: null,
},
{
domain: 'example.net',
available: false,
price: null,
premium: false,
premium_price: null,
},
];
const mockDomainPrices = [
{ tld: '.com', registration: 12.99, renewal: 12.99, transfer: 12.99 },
{ tld: '.net', registration: 14.99, renewal: 14.99, transfer: 14.99 },
{ tld: '.org', registration: 13.99, renewal: 13.99, transfer: 13.99 },
];
const mockDomainRegistration = {
id: 1,
domain: 'example.com',
status: 'active' as const,
registered_at: '2024-01-01T00:00:00Z',
expires_at: '2025-01-01T00:00:00Z',
auto_renew: true,
whois_privacy: true,
purchase_price: 12.99,
renewal_price: 12.99,
nameservers: ['ns1.smoothschedule.com', 'ns2.smoothschedule.com'],
days_until_expiry: 365,
is_expiring_soon: false,
created_at: '2024-01-01T00:00:00Z',
registrant_first_name: 'John',
registrant_last_name: 'Doe',
registrant_email: 'john@example.com',
};
const mockRegisteredDomains = [
mockDomainRegistration,
{
...mockDomainRegistration,
id: 2,
domain: 'another.com',
auto_renew: false,
},
];
const mockSearchHistory = [
{
id: 1,
searched_domain: 'example.com',
was_available: true,
price: 12.99,
searched_at: '2024-01-01T00:00:00Z',
},
{
id: 2,
searched_domain: 'taken.com',
was_available: false,
price: null,
searched_at: '2024-01-02T00:00:00Z',
},
];
const mockRegisterRequest = {
domain: 'example.com',
years: 1,
whois_privacy: true,
auto_renew: true,
nameservers: ['ns1.smoothschedule.com', 'ns2.smoothschedule.com'],
contact: {
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
phone: '+1234567890',
address: '123 Main St',
city: 'New York',
state: 'NY',
zip_code: '10001',
country: 'US',
},
auto_configure: true,
};
describe('useDomains hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ============================================
// Search & Pricing
// ============================================
describe('useDomainSearch', () => {
it('searches for domain availability', async () => {
vi.mocked(domainsApi.searchDomains).mockResolvedValue(mockDomainAvailability);
const { result } = renderHook(() => useDomainSearch(), {
wrapper: createWrapper(),
});
let searchData;
await act(async () => {
searchData = await result.current.mutateAsync({
query: 'example',
tlds: ['.com', '.net'],
});
});
expect(domainsApi.searchDomains).toHaveBeenCalledWith('example', ['.com', '.net']);
expect(searchData).toEqual(mockDomainAvailability);
});
it('uses default TLDs when not provided', async () => {
vi.mocked(domainsApi.searchDomains).mockResolvedValue(mockDomainAvailability);
const { result } = renderHook(() => useDomainSearch(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ query: 'example' });
});
expect(domainsApi.searchDomains).toHaveBeenCalledWith('example', undefined);
});
it('invalidates search history on successful search', async () => {
vi.mocked(domainsApi.searchDomains).mockResolvedValue(mockDomainAvailability);
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue(mockSearchHistory);
const wrapper = createWrapper();
// First render the search history hook
const { result: historyResult } = renderHook(() => useSearchHistory(), {
wrapper,
});
await waitFor(() => {
expect(historyResult.current.isSuccess).toBe(true);
});
// Now perform a search which should invalidate history
const { result: searchResult } = renderHook(() => useDomainSearch(), {
wrapper,
});
await act(async () => {
await searchResult.current.mutateAsync({ query: 'example' });
});
// Verify search history was called again (invalidated and refetched)
await waitFor(() => {
expect(domainsApi.getSearchHistory).toHaveBeenCalledTimes(2);
});
});
it('handles search errors', async () => {
vi.mocked(domainsApi.searchDomains).mockRejectedValue(new Error('Search failed'));
const { result } = renderHook(() => useDomainSearch(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ query: 'example' });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Search failed'));
});
});
describe('useDomainPrices', () => {
it('fetches domain prices', async () => {
vi.mocked(domainsApi.getDomainPrices).mockResolvedValue(mockDomainPrices);
const { result } = renderHook(() => useDomainPrices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getDomainPrices).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockDomainPrices);
});
it('uses staleTime of 5 minutes', async () => {
vi.mocked(domainsApi.getDomainPrices).mockResolvedValue(mockDomainPrices);
const { result } = renderHook(() => useDomainPrices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Verify staleTime is configured
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles price fetch errors', async () => {
vi.mocked(domainsApi.getDomainPrices).mockRejectedValue(new Error('Price fetch failed'));
const { result } = renderHook(() => useDomainPrices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Price fetch failed'));
});
});
// ============================================
// Registration
// ============================================
describe('useRegisterDomain', () => {
it('registers a new domain', async () => {
vi.mocked(domainsApi.registerDomain).mockResolvedValue(mockDomainRegistration);
const { result } = renderHook(() => useRegisterDomain(), {
wrapper: createWrapper(),
});
let registrationData;
await act(async () => {
registrationData = await result.current.mutateAsync(mockRegisterRequest);
});
expect(domainsApi.registerDomain).toHaveBeenCalledWith(mockRegisterRequest);
expect(registrationData).toEqual(mockDomainRegistration);
});
it('invalidates registrations and customDomains on success', async () => {
vi.mocked(domainsApi.registerDomain).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// First render registered domains
const { result: domainsResult } = renderHook(() => useRegisteredDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Register a new domain
const { result: registerResult } = renderHook(() => useRegisterDomain(), {
wrapper,
});
await act(async () => {
await registerResult.current.mutateAsync(mockRegisterRequest);
});
// Verify registrations were invalidated and refetched
await waitFor(() => {
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(2);
});
});
it('handles registration errors', async () => {
vi.mocked(domainsApi.registerDomain).mockRejectedValue(
new Error('Registration failed')
);
const { result } = renderHook(() => useRegisterDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(mockRegisterRequest);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Registration failed'));
});
});
describe('useRegisteredDomains', () => {
it('fetches all registered domains', async () => {
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const { result } = renderHook(() => useRegisteredDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockRegisteredDomains);
expect(result.current.data).toHaveLength(2);
});
it('uses staleTime of 30 seconds', async () => {
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const { result } = renderHook(() => useRegisteredDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles fetch errors', async () => {
vi.mocked(domainsApi.getRegisteredDomains).mockRejectedValue(
new Error('Fetch failed')
);
const { result } = renderHook(() => useRegisteredDomains(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Fetch failed'));
});
});
describe('useDomainRegistration', () => {
it('fetches single domain registration by id', async () => {
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
const { result } = renderHook(() => useDomainRegistration(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getDomainRegistration).toHaveBeenCalledWith(1);
expect(result.current.data).toEqual(mockDomainRegistration);
});
it('does not fetch when id is 0', async () => {
const { result } = renderHook(() => useDomainRegistration(0), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(domainsApi.getDomainRegistration).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
it('does not fetch when id is null/undefined', async () => {
const { result } = renderHook(() => useDomainRegistration(null as any), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(domainsApi.getDomainRegistration).not.toHaveBeenCalled();
});
it('handles fetch errors', async () => {
vi.mocked(domainsApi.getDomainRegistration).mockRejectedValue(
new Error('Domain not found')
);
const { result } = renderHook(() => useDomainRegistration(999), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('Domain not found'));
});
});
// ============================================
// Domain Management
// ============================================
describe('useUpdateNameservers', () => {
it('updates nameservers for a domain', async () => {
const updatedDomain = {
...mockDomainRegistration,
nameservers: ['ns1.custom.com', 'ns2.custom.com'],
};
vi.mocked(domainsApi.updateNameservers).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useUpdateNameservers(), {
wrapper: createWrapper(),
});
let updateData;
await act(async () => {
updateData = await result.current.mutateAsync({
id: 1,
nameservers: ['ns1.custom.com', 'ns2.custom.com'],
});
});
expect(domainsApi.updateNameservers).toHaveBeenCalledWith(1, [
'ns1.custom.com',
'ns2.custom.com',
]);
expect(updateData).toEqual(updatedDomain);
});
it('updates cache optimistically with setQueryData', async () => {
const updatedDomain = {
...mockDomainRegistration,
nameservers: ['ns1.new.com', 'ns2.new.com'],
};
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.updateNameservers).mockResolvedValue(updatedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// First fetch the domain
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.isSuccess).toBe(true);
});
expect(domainResult.current.data?.nameservers).toEqual([
'ns1.smoothschedule.com',
'ns2.smoothschedule.com',
]);
// Now update nameservers
const { result: updateResult } = renderHook(() => useUpdateNameservers(), {
wrapper,
});
let updateData;
await act(async () => {
updateData = await updateResult.current.mutateAsync({
id: 1,
nameservers: ['ns1.new.com', 'ns2.new.com'],
});
});
// Verify the mutation returned updated data
expect(updateData).toEqual(updatedDomain);
// Refetch to get updated cache
await act(async () => {
await domainResult.current.refetch();
});
});
it('invalidates registrations list', async () => {
vi.mocked(domainsApi.updateNameservers).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// Fetch registrations first
const { result: domainsResult } = renderHook(() => useRegisteredDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
// Update nameservers
const { result: updateResult } = renderHook(() => useUpdateNameservers(), {
wrapper,
});
await act(async () => {
await updateResult.current.mutateAsync({
id: 1,
nameservers: ['ns1.new.com'],
});
});
// Registrations should be refetched
await waitFor(() => {
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(2);
});
});
it('handles update errors', async () => {
vi.mocked(domainsApi.updateNameservers).mockRejectedValue(
new Error('Update failed')
);
const { result } = renderHook(() => useUpdateNameservers(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 1, nameservers: ['ns1.new.com'] });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Update failed'));
});
});
describe('useToggleAutoRenew', () => {
it('toggles auto-renewal on', async () => {
const updatedDomain = { ...mockDomainRegistration, auto_renew: true };
vi.mocked(domainsApi.toggleAutoRenew).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useToggleAutoRenew(), {
wrapper: createWrapper(),
});
let toggleData;
await act(async () => {
toggleData = await result.current.mutateAsync({ id: 1, autoRenew: true });
});
expect(domainsApi.toggleAutoRenew).toHaveBeenCalledWith(1, true);
expect(toggleData?.auto_renew).toBe(true);
});
it('toggles auto-renewal off', async () => {
const updatedDomain = { ...mockDomainRegistration, auto_renew: false };
vi.mocked(domainsApi.toggleAutoRenew).mockResolvedValue(updatedDomain);
const { result } = renderHook(() => useToggleAutoRenew(), {
wrapper: createWrapper(),
});
let toggleData;
await act(async () => {
toggleData = await result.current.mutateAsync({ id: 1, autoRenew: false });
});
expect(domainsApi.toggleAutoRenew).toHaveBeenCalledWith(1, false);
expect(toggleData?.auto_renew).toBe(false);
});
it('updates cache with setQueryData', async () => {
const updatedDomain = { ...mockDomainRegistration, auto_renew: false };
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.toggleAutoRenew).mockResolvedValue(updatedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
// Fetch domain first
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.isSuccess).toBe(true);
});
expect(domainResult.current.data?.auto_renew).toBe(true);
// Toggle auto-renew
const { result: toggleResult } = renderHook(() => useToggleAutoRenew(), {
wrapper,
});
let toggleData;
await act(async () => {
toggleData = await toggleResult.current.mutateAsync({ id: 1, autoRenew: false });
});
// Verify mutation returned updated data
expect(toggleData?.auto_renew).toBe(false);
// Refetch to verify cache updated
await act(async () => {
await domainResult.current.refetch();
});
});
it('handles toggle errors', async () => {
vi.mocked(domainsApi.toggleAutoRenew).mockRejectedValue(
new Error('Toggle failed')
);
const { result } = renderHook(() => useToggleAutoRenew(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 1, autoRenew: false });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Toggle failed'));
});
});
describe('useRenewDomain', () => {
it('renews domain for 1 year by default', async () => {
const renewedDomain = {
...mockDomainRegistration,
expires_at: '2026-01-01T00:00:00Z',
days_until_expiry: 730,
};
vi.mocked(domainsApi.renewDomain).mockResolvedValue(renewedDomain);
const { result } = renderHook(() => useRenewDomain(), {
wrapper: createWrapper(),
});
let renewData;
await act(async () => {
renewData = await result.current.mutateAsync({ id: 1 });
});
expect(domainsApi.renewDomain).toHaveBeenCalledWith(1, undefined);
expect(renewData).toEqual(renewedDomain);
});
it('renews domain for specified years', async () => {
const renewedDomain = {
...mockDomainRegistration,
expires_at: '2027-01-01T00:00:00Z',
days_until_expiry: 1095,
};
vi.mocked(domainsApi.renewDomain).mockResolvedValue(renewedDomain);
const { result } = renderHook(() => useRenewDomain(), {
wrapper: createWrapper(),
});
let renewData;
await act(async () => {
renewData = await result.current.mutateAsync({ id: 1, years: 3 });
});
expect(domainsApi.renewDomain).toHaveBeenCalledWith(1, 3);
expect(renewData).toEqual(renewedDomain);
});
it('updates cache with renewed domain data', async () => {
const renewedDomain = {
...mockDomainRegistration,
expires_at: '2026-01-01T00:00:00Z',
};
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.renewDomain).mockResolvedValue(renewedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.data?.expires_at).toBe('2025-01-01T00:00:00Z');
});
const { result: renewResult } = renderHook(() => useRenewDomain(), {
wrapper,
});
let renewData;
await act(async () => {
renewData = await renewResult.current.mutateAsync({ id: 1, years: 1 });
});
// Verify mutation returned updated data
expect(renewData?.expires_at).toBe('2026-01-01T00:00:00Z');
// Refetch to verify cache updated
await act(async () => {
await domainResult.current.refetch();
});
});
it('handles renewal errors', async () => {
vi.mocked(domainsApi.renewDomain).mockRejectedValue(new Error('Renewal failed'));
const { result } = renderHook(() => useRenewDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 1 });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Renewal failed'));
});
});
describe('useSyncDomain', () => {
it('syncs domain info from NameSilo', async () => {
const syncedDomain = {
...mockDomainRegistration,
expires_at: '2025-06-01T00:00:00Z',
days_until_expiry: 515,
};
vi.mocked(domainsApi.syncDomain).mockResolvedValue(syncedDomain);
const { result } = renderHook(() => useSyncDomain(), {
wrapper: createWrapper(),
});
let syncData;
await act(async () => {
syncData = await result.current.mutateAsync(1);
});
expect(domainsApi.syncDomain).toHaveBeenCalledWith(1);
expect(syncData).toEqual(syncedDomain);
});
it('updates cache with synced data', async () => {
const syncedDomain = {
...mockDomainRegistration,
status: 'active' as const,
expires_at: '2025-12-31T00:00:00Z',
};
vi.mocked(domainsApi.getDomainRegistration).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.syncDomain).mockResolvedValue(syncedDomain);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
const { result: domainResult } = renderHook(() => useDomainRegistration(1), {
wrapper,
});
await waitFor(() => {
expect(domainResult.current.isSuccess).toBe(true);
});
expect(domainResult.current.data?.expires_at).toBe('2025-01-01T00:00:00Z');
const { result: syncResult } = renderHook(() => useSyncDomain(), {
wrapper,
});
let syncData;
await act(async () => {
syncData = await syncResult.current.mutateAsync(1);
});
// Verify mutation returned updated data
expect(syncData?.expires_at).toBe('2025-12-31T00:00:00Z');
// Refetch to verify cache updated
await act(async () => {
await domainResult.current.refetch();
});
});
it('invalidates registrations list after sync', async () => {
vi.mocked(domainsApi.syncDomain).mockResolvedValue(mockDomainRegistration);
vi.mocked(domainsApi.getRegisteredDomains).mockResolvedValue(mockRegisteredDomains);
const wrapper = createWrapper();
const { result: domainsResult } = renderHook(() => useRegisteredDomains(), {
wrapper,
});
await waitFor(() => {
expect(domainsResult.current.isSuccess).toBe(true);
});
const { result: syncResult } = renderHook(() => useSyncDomain(), {
wrapper,
});
await act(async () => {
await syncResult.current.mutateAsync(1);
});
await waitFor(() => {
expect(domainsApi.getRegisteredDomains).toHaveBeenCalledTimes(2);
});
});
it('handles sync errors', async () => {
vi.mocked(domainsApi.syncDomain).mockRejectedValue(new Error('Sync failed'));
const { result } = renderHook(() => useSyncDomain(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(1);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(new Error('Sync failed'));
});
});
// ============================================
// History
// ============================================
describe('useSearchHistory', () => {
it('fetches search history', async () => {
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue(mockSearchHistory);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(domainsApi.getSearchHistory).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockSearchHistory);
expect(result.current.data).toHaveLength(2);
});
it('uses staleTime of 1 minute', async () => {
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue(mockSearchHistory);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.dataUpdatedAt).toBeGreaterThan(0);
});
it('handles empty search history', async () => {
vi.mocked(domainsApi.getSearchHistory).mockResolvedValue([]);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles fetch errors', async () => {
vi.mocked(domainsApi.getSearchHistory).mockRejectedValue(
new Error('History fetch failed')
);
const { result } = renderHook(() => useSearchHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(new Error('History fetch failed'));
});
});
});

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

View File

@@ -0,0 +1,142 @@
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 the notifications API
vi.mock('../../api/notifications', () => ({
getNotifications: vi.fn(),
getUnreadCount: vi.fn(),
markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(),
clearAllNotifications: vi.fn(),
}));
import {
useNotifications,
useUnreadNotificationCount,
useMarkNotificationRead,
useMarkAllNotificationsRead,
useClearAllNotifications,
} from '../useNotifications';
import * as notificationsApi from '../../api/notifications';
// 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('useNotifications hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useNotifications', () => {
it('fetches notifications', async () => {
const mockNotifications = [
{ id: 1, verb: 'created', read: false, timestamp: '2024-01-01T00:00:00Z' },
];
vi.mocked(notificationsApi.getNotifications).mockResolvedValue(mockNotifications);
const { result } = renderHook(() => useNotifications(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(notificationsApi.getNotifications).toHaveBeenCalledWith(undefined);
expect(result.current.data).toEqual(mockNotifications);
});
it('passes options to API', async () => {
vi.mocked(notificationsApi.getNotifications).mockResolvedValue([]);
renderHook(() => useNotifications({ read: false, limit: 10 }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(notificationsApi.getNotifications).toHaveBeenCalledWith({
read: false,
limit: 10,
});
});
});
});
describe('useUnreadNotificationCount', () => {
it('fetches unread count', async () => {
vi.mocked(notificationsApi.getUnreadCount).mockResolvedValue(5);
const { result } = renderHook(() => useUnreadNotificationCount(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBe(5);
});
});
describe('useMarkNotificationRead', () => {
it('marks notification as read', async () => {
vi.mocked(notificationsApi.markNotificationRead).mockResolvedValue(undefined);
const { result } = renderHook(() => useMarkNotificationRead(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(42);
});
expect(notificationsApi.markNotificationRead).toHaveBeenCalled();
expect(vi.mocked(notificationsApi.markNotificationRead).mock.calls[0][0]).toBe(42);
});
});
describe('useMarkAllNotificationsRead', () => {
it('marks all notifications as read', async () => {
vi.mocked(notificationsApi.markAllNotificationsRead).mockResolvedValue(undefined);
const { result } = renderHook(() => useMarkAllNotificationsRead(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(notificationsApi.markAllNotificationsRead).toHaveBeenCalled();
});
});
describe('useClearAllNotifications', () => {
it('clears all notifications', async () => {
vi.mocked(notificationsApi.clearAllNotifications).mockResolvedValue(undefined);
const { result } = renderHook(() => useClearAllNotifications(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(notificationsApi.clearAllNotifications).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,549 @@
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 dependencies
vi.mock('../../api/oauth', () => ({
getOAuthProviders: vi.fn(),
getOAuthConnections: vi.fn(),
initiateOAuth: vi.fn(),
handleOAuthCallback: vi.fn(),
disconnectOAuth: vi.fn(),
}));
vi.mock('../../utils/cookies', () => ({
setCookie: vi.fn(),
}));
import {
useOAuthProviders,
useOAuthConnections,
useInitiateOAuth,
useOAuthCallback,
useDisconnectOAuth,
} from '../useOAuth';
import * as oauthApi from '../../api/oauth';
import * as cookies from '../../utils/cookies';
// Create a wrapper with QueryClientProvider
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
);
};
};
describe('useOAuth hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useOAuthProviders', () => {
it('fetches OAuth providers successfully', async () => {
const mockProviders: oauthApi.OAuthProvider[] = [
{
name: 'google',
display_name: 'Google',
icon: 'https://example.com/google.png',
},
{
name: 'microsoft',
display_name: 'Microsoft',
icon: 'https://example.com/microsoft.png',
},
];
vi.mocked(oauthApi.getOAuthProviders).mockResolvedValue(mockProviders);
const { result } = renderHook(() => useOAuthProviders(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockProviders);
expect(oauthApi.getOAuthProviders).toHaveBeenCalledTimes(1);
});
it('handles errors when fetching providers fails', async () => {
const mockError = new Error('Failed to fetch providers');
vi.mocked(oauthApi.getOAuthProviders).mockRejectedValue(mockError);
const { result } = renderHook(() => useOAuthProviders(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isError).toBe(true);
expect(result.current.error).toEqual(mockError);
});
it('uses correct query configuration', () => {
vi.mocked(oauthApi.getOAuthProviders).mockResolvedValue([]);
const { result } = renderHook(() => useOAuthProviders(), {
wrapper: createWrapper(),
});
// The hook should be configured with staleTime and refetchOnWindowFocus
// We can verify this by checking that the hook doesn't refetch immediately
expect(result.current.isLoading).toBe(true);
});
});
describe('useOAuthConnections', () => {
it('fetches OAuth connections successfully', async () => {
const mockConnections: oauthApi.OAuthConnection[] = [
{
id: '1',
provider: 'google',
provider_user_id: 'user123',
email: 'test@example.com',
connected_at: '2025-01-01T00:00:00Z',
},
{
id: '2',
provider: 'microsoft',
provider_user_id: 'user456',
email: 'test@microsoft.com',
connected_at: '2025-01-02T00:00:00Z',
},
];
vi.mocked(oauthApi.getOAuthConnections).mockResolvedValue(mockConnections);
const { result } = renderHook(() => useOAuthConnections(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockConnections);
expect(oauthApi.getOAuthConnections).toHaveBeenCalledTimes(1);
});
it('handles errors when fetching connections fails', async () => {
const mockError = new Error('Failed to fetch connections');
vi.mocked(oauthApi.getOAuthConnections).mockRejectedValue(mockError);
const { result } = renderHook(() => useOAuthConnections(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isError).toBe(true);
expect(result.current.error).toEqual(mockError);
});
it('returns empty array when no connections exist', async () => {
vi.mocked(oauthApi.getOAuthConnections).mockResolvedValue([]);
const { result } = renderHook(() => useOAuthConnections(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual([]);
});
});
describe('useInitiateOAuth', () => {
it('initiates OAuth flow and redirects to authorization URL', async () => {
const mockAuthUrl = 'https://accounts.google.com/oauth/authorize?client_id=123';
vi.mocked(oauthApi.initiateOAuth).mockResolvedValue({
authorization_url: mockAuthUrl,
});
// Mock window.location
const originalLocation = window.location;
delete (window as any).location;
window.location = { ...originalLocation, href: '' } as Location;
const { result } = renderHook(() => useInitiateOAuth(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('google');
});
expect(oauthApi.initiateOAuth).toHaveBeenCalledWith('google');
expect(window.location.href).toBe(mockAuthUrl);
// Restore window.location
window.location = originalLocation;
});
it('handles errors when initiating OAuth fails', async () => {
const mockError = new Error('Failed to initiate OAuth');
vi.mocked(oauthApi.initiateOAuth).mockRejectedValue(mockError);
const { result } = renderHook(() => useInitiateOAuth(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('google');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('supports multiple OAuth providers', async () => {
const providers = ['google', 'microsoft', 'github'];
for (const provider of providers) {
vi.mocked(oauthApi.initiateOAuth).mockResolvedValue({
authorization_url: `https://${provider}.com/oauth/authorize`,
});
const originalLocation = window.location;
delete (window as any).location;
window.location = { ...originalLocation, href: '' } as Location;
const { result } = renderHook(() => useInitiateOAuth(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(provider);
});
expect(oauthApi.initiateOAuth).toHaveBeenCalledWith(provider);
expect(window.location.href).toBe(`https://${provider}.com/oauth/authorize`);
window.location = originalLocation;
vi.clearAllMocks();
}
});
});
describe('useOAuthCallback', () => {
it('handles OAuth callback and stores tokens in cookies', async () => {
const mockResponse: oauthApi.OAuthTokenResponse = {
access: 'access-token-123',
refresh: 'refresh-token-456',
user: {
id: 1,
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
role: 'owner',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useOAuthCallback(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
provider: 'google',
code: 'auth-code-123',
state: 'state-456',
});
});
expect(oauthApi.handleOAuthCallback).toHaveBeenCalledWith(
'google',
'auth-code-123',
'state-456'
);
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-token-123', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-token-456', 7);
});
it('sets user in cache after successful callback', async () => {
const mockUser = {
id: 1,
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
role: 'owner',
is_staff: false,
is_superuser: false,
};
const mockResponse: oauthApi.OAuthTokenResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: mockUser,
};
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useOAuthCallback(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
provider: 'google',
code: 'code',
state: 'state',
});
});
// Verify user was set in cache
const cachedUser = queryClient.getQueryData(['currentUser']);
expect(cachedUser).toEqual(mockUser);
});
it('invalidates OAuth connections after successful callback', async () => {
const mockResponse: oauthApi.OAuthTokenResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 1,
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
role: 'owner',
is_staff: false,
is_superuser: false,
},
};
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Set initial connections data
queryClient.setQueryData(['oauthConnections'], []);
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useOAuthCallback(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
provider: 'google',
code: 'code',
state: 'state',
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['oauthConnections'] });
});
it('handles errors during OAuth callback', async () => {
const mockError = new Error('Invalid authorization code');
vi.mocked(oauthApi.handleOAuthCallback).mockRejectedValue(mockError);
const { result } = renderHook(() => useOAuthCallback(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({
provider: 'google',
code: 'invalid-code',
state: 'state',
});
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
expect(cookies.setCookie).not.toHaveBeenCalled();
});
it('handles callback with optional user fields', async () => {
const mockResponse: oauthApi.OAuthTokenResponse = {
access: 'access-token',
refresh: 'refresh-token',
user: {
id: 1,
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
role: 'owner',
avatar_url: 'https://example.com/avatar.png',
is_staff: true,
is_superuser: false,
business: 123,
business_name: 'Test Business',
business_subdomain: 'testbiz',
},
};
vi.mocked(oauthApi.handleOAuthCallback).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useOAuthCallback(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
provider: 'microsoft',
code: 'code',
state: 'state',
});
});
expect(cookies.setCookie).toHaveBeenCalledWith('access_token', 'access-token', 7);
expect(cookies.setCookie).toHaveBeenCalledWith('refresh_token', 'refresh-token', 7);
});
});
describe('useDisconnectOAuth', () => {
it('disconnects OAuth provider successfully', async () => {
vi.mocked(oauthApi.disconnectOAuth).mockResolvedValue(undefined);
const { result } = renderHook(() => useDisconnectOAuth(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('google');
});
// React Query passes mutation context as second parameter
expect(oauthApi.disconnectOAuth).toHaveBeenCalledWith('google', expect.any(Object));
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
it('invalidates OAuth connections after disconnect', async () => {
vi.mocked(oauthApi.disconnectOAuth).mockResolvedValue(undefined);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDisconnectOAuth(), { wrapper });
await act(async () => {
await result.current.mutateAsync('google');
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['oauthConnections'] });
});
it('handles errors when disconnect fails', async () => {
const mockError = new Error('Failed to disconnect');
vi.mocked(oauthApi.disconnectOAuth).mockRejectedValue(mockError);
const { result } = renderHook(() => useDisconnectOAuth(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync('google');
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toEqual(mockError);
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toEqual(mockError);
});
it('can disconnect multiple providers sequentially', async () => {
vi.mocked(oauthApi.disconnectOAuth).mockResolvedValue(undefined);
const { result } = renderHook(() => useDisconnectOAuth(), {
wrapper: createWrapper(),
});
// Disconnect first provider
await act(async () => {
await result.current.mutateAsync('google');
});
// React Query passes mutation context as second parameter
expect(oauthApi.disconnectOAuth).toHaveBeenNthCalledWith(1, 'google', expect.any(Object));
// Disconnect second provider
await act(async () => {
await result.current.mutateAsync('microsoft');
});
expect(oauthApi.disconnectOAuth).toHaveBeenNthCalledWith(2, 'microsoft', expect.any(Object));
expect(oauthApi.disconnectOAuth).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,584 @@
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 the payments API module
vi.mock('../../api/payments', () => ({
getPaymentConfig: vi.fn(),
getApiKeys: vi.fn(),
validateApiKeys: vi.fn(),
saveApiKeys: vi.fn(),
revalidateApiKeys: vi.fn(),
deleteApiKeys: vi.fn(),
getConnectStatus: vi.fn(),
initiateConnectOnboarding: vi.fn(),
refreshConnectOnboardingLink: vi.fn(),
}));
import {
usePaymentConfig,
useApiKeys,
useValidateApiKeys,
useSaveApiKeys,
useRevalidateApiKeys,
useDeleteApiKeys,
useConnectStatus,
useConnectOnboarding,
useRefreshConnectLink,
paymentKeys,
} from '../usePayments';
import * as paymentsApi from '../../api/payments';
// Create wrapper with fresh QueryClient for each test
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('usePayments hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('paymentKeys', () => {
it('generates correct query keys', () => {
expect(paymentKeys.all).toEqual(['payments']);
expect(paymentKeys.config()).toEqual(['payments', 'config']);
expect(paymentKeys.apiKeys()).toEqual(['payments', 'apiKeys']);
expect(paymentKeys.connectStatus()).toEqual(['payments', 'connectStatus']);
});
});
describe('usePaymentConfig', () => {
it('fetches payment configuration', async () => {
const mockConfig = {
payment_mode: 'direct_api' as const,
tier: 'free',
tier_allows_payments: true,
stripe_configured: true,
can_accept_payments: true,
api_keys: {
id: 1,
status: 'active' as const,
secret_key_masked: 'sk_test_****1234',
publishable_key_masked: 'pk_test_****5678',
last_validated_at: '2025-12-07T10:00:00Z',
stripe_account_id: 'acct_123',
stripe_account_name: 'Test Business',
validation_error: '',
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
},
connect_account: null,
};
vi.mocked(paymentsApi.getPaymentConfig).mockResolvedValue({ data: mockConfig } as any);
const { result } = renderHook(() => usePaymentConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getPaymentConfig).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockConfig);
});
it('uses 30 second staleTime', async () => {
vi.mocked(paymentsApi.getPaymentConfig).mockResolvedValue({ data: {} } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => usePaymentConfig(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(paymentKeys.config());
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(paymentKeys.config());
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useApiKeys', () => {
it('fetches current API keys configuration', async () => {
const mockApiKeys = {
configured: true,
id: 1,
status: 'active' as const,
secret_key_masked: 'sk_test_****1234',
publishable_key_masked: 'pk_test_****5678',
last_validated_at: '2025-12-07T10:00:00Z',
stripe_account_id: 'acct_123',
stripe_account_name: 'Test Business',
validation_error: '',
};
vi.mocked(paymentsApi.getApiKeys).mockResolvedValue({ data: mockApiKeys } as any);
const { result } = renderHook(() => useApiKeys(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getApiKeys).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockApiKeys);
});
it('handles unconfigured state', async () => {
const mockApiKeys = {
configured: false,
message: 'No API keys configured',
};
vi.mocked(paymentsApi.getApiKeys).mockResolvedValue({ data: mockApiKeys } as any);
const { result } = renderHook(() => useApiKeys(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.configured).toBe(false);
expect(result.current.data?.message).toBe('No API keys configured');
});
});
describe('useValidateApiKeys', () => {
it('validates API keys successfully', async () => {
const mockValidationResult = {
valid: true,
account_id: 'acct_123',
account_name: 'Test Account',
environment: 'test',
};
vi.mocked(paymentsApi.validateApiKeys).mockResolvedValue({ data: mockValidationResult } as any);
const { result } = renderHook(() => useValidateApiKeys(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync({
secretKey: 'sk_test_123',
publishableKey: 'pk_test_456',
});
expect(response).toEqual(mockValidationResult);
});
expect(paymentsApi.validateApiKeys).toHaveBeenCalledWith('sk_test_123', 'pk_test_456');
});
it('handles validation failure', async () => {
const mockValidationResult = {
valid: false,
error: 'Invalid API keys',
};
vi.mocked(paymentsApi.validateApiKeys).mockResolvedValue({ data: mockValidationResult } as any);
const { result } = renderHook(() => useValidateApiKeys(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync({
secretKey: 'sk_test_invalid',
publishableKey: 'pk_test_invalid',
});
expect(response.valid).toBe(false);
expect(response.error).toBe('Invalid API keys');
});
});
});
describe('useSaveApiKeys', () => {
it('saves API keys successfully', async () => {
const mockSavedKeys = {
id: 1,
status: 'active' as const,
secret_key_masked: 'sk_test_****1234',
publishable_key_masked: 'pk_test_****5678',
last_validated_at: '2025-12-07T10:00:00Z',
stripe_account_id: 'acct_123',
stripe_account_name: 'Test Business',
validation_error: '',
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(paymentsApi.saveApiKeys).mockResolvedValue({ data: mockSavedKeys } as any);
const { result } = renderHook(() => useSaveApiKeys(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync({
secretKey: 'sk_test_123',
publishableKey: 'pk_test_456',
});
expect(response).toEqual(mockSavedKeys);
});
expect(paymentsApi.saveApiKeys).toHaveBeenCalledWith('sk_test_123', 'pk_test_456');
});
it('invalidates payment config and api keys queries on success', async () => {
vi.mocked(paymentsApi.saveApiKeys).mockResolvedValue({ data: {} } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useSaveApiKeys(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
secretKey: 'sk_test_123',
publishableKey: 'pk_test_456',
});
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.config() });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.apiKeys() });
});
});
describe('useRevalidateApiKeys', () => {
it('revalidates stored API keys', async () => {
const mockValidationResult = {
valid: true,
account_id: 'acct_123',
account_name: 'Test Account',
environment: 'test',
};
vi.mocked(paymentsApi.revalidateApiKeys).mockResolvedValue({ data: mockValidationResult } as any);
const { result } = renderHook(() => useRevalidateApiKeys(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync();
expect(response).toEqual(mockValidationResult);
});
expect(paymentsApi.revalidateApiKeys).toHaveBeenCalledTimes(1);
});
it('invalidates payment config and api keys queries on success', async () => {
vi.mocked(paymentsApi.revalidateApiKeys).mockResolvedValue({ data: { valid: true } } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useRevalidateApiKeys(), { wrapper });
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.config() });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.apiKeys() });
});
});
describe('useDeleteApiKeys', () => {
it('deletes API keys successfully', async () => {
const mockDeleteResponse = {
success: true,
message: 'API keys deleted successfully',
};
vi.mocked(paymentsApi.deleteApiKeys).mockResolvedValue({ data: mockDeleteResponse } as any);
const { result } = renderHook(() => useDeleteApiKeys(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync();
expect(response).toEqual(mockDeleteResponse);
});
expect(paymentsApi.deleteApiKeys).toHaveBeenCalledTimes(1);
});
it('invalidates payment config and api keys queries on success', async () => {
vi.mocked(paymentsApi.deleteApiKeys).mockResolvedValue({ data: { success: true } } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeleteApiKeys(), { wrapper });
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.config() });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.apiKeys() });
});
});
describe('useConnectStatus', () => {
it('fetches Connect account status', async () => {
const mockConnectStatus = {
id: 1,
business: 1,
business_name: 'Test Business',
business_subdomain: 'test',
stripe_account_id: 'acct_connect_123',
account_type: 'standard' as const,
status: 'active' as const,
charges_enabled: true,
payouts_enabled: true,
details_submitted: true,
onboarding_complete: true,
onboarding_link: null,
onboarding_link_expires_at: null,
is_onboarding_link_valid: false,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(paymentsApi.getConnectStatus).mockResolvedValue({ data: mockConnectStatus } as any);
const { result } = renderHook(() => useConnectStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getConnectStatus).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockConnectStatus);
});
it('handles onboarding state with valid link', async () => {
const mockConnectStatus = {
id: 1,
business: 1,
business_name: 'Test Business',
business_subdomain: 'test',
stripe_account_id: 'acct_connect_123',
account_type: 'custom' as const,
status: 'onboarding' as const,
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
onboarding_complete: false,
onboarding_link: 'https://connect.stripe.com/setup/...',
onboarding_link_expires_at: '2025-12-08T10:00:00Z',
is_onboarding_link_valid: true,
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(paymentsApi.getConnectStatus).mockResolvedValue({ data: mockConnectStatus } as any);
const { result } = renderHook(() => useConnectStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.onboarding_link).toBe('https://connect.stripe.com/setup/...');
expect(result.current.data?.is_onboarding_link_valid).toBe(true);
});
it('is enabled by default', async () => {
vi.mocked(paymentsApi.getConnectStatus).mockResolvedValue({ data: {} } as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => useConnectStatus(), { wrapper });
await waitFor(() => {
expect(paymentsApi.getConnectStatus).toHaveBeenCalled();
});
});
});
describe('useConnectOnboarding', () => {
it('initiates Connect onboarding successfully', async () => {
const mockOnboardingResponse = {
account_type: 'standard' as const,
url: 'https://connect.stripe.com/setup/s/acct_123/abc123',
stripe_account_id: 'acct_123',
};
vi.mocked(paymentsApi.initiateConnectOnboarding).mockResolvedValue({ data: mockOnboardingResponse } as any);
const { result } = renderHook(() => useConnectOnboarding(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync({
refreshUrl: 'http://test.lvh.me:5173/payments/refresh',
returnUrl: 'http://test.lvh.me:5173/payments/complete',
});
expect(response).toEqual(mockOnboardingResponse);
});
expect(paymentsApi.initiateConnectOnboarding).toHaveBeenCalledWith(
'http://test.lvh.me:5173/payments/refresh',
'http://test.lvh.me:5173/payments/complete'
);
});
it('invalidates payment config and connect status queries on success', async () => {
vi.mocked(paymentsApi.initiateConnectOnboarding).mockResolvedValue({
data: { account_type: 'standard', url: 'https://stripe.com' }
} as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useConnectOnboarding(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
refreshUrl: 'http://test.lvh.me:5173/refresh',
returnUrl: 'http://test.lvh.me:5173/return',
});
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.config() });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.connectStatus() });
});
});
describe('useRefreshConnectLink', () => {
it('refreshes Connect onboarding link successfully', async () => {
const mockRefreshResponse = {
url: 'https://connect.stripe.com/setup/s/acct_123/xyz789',
};
vi.mocked(paymentsApi.refreshConnectOnboardingLink).mockResolvedValue({ data: mockRefreshResponse } as any);
const { result } = renderHook(() => useRefreshConnectLink(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync({
refreshUrl: 'http://test.lvh.me:5173/payments/refresh',
returnUrl: 'http://test.lvh.me:5173/payments/complete',
});
expect(response).toEqual(mockRefreshResponse);
});
expect(paymentsApi.refreshConnectOnboardingLink).toHaveBeenCalledWith(
'http://test.lvh.me:5173/payments/refresh',
'http://test.lvh.me:5173/payments/complete'
);
});
it('invalidates connect status query on success', async () => {
vi.mocked(paymentsApi.refreshConnectOnboardingLink).mockResolvedValue({
data: { url: 'https://stripe.com' }
} as any);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useRefreshConnectLink(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
refreshUrl: 'http://test.lvh.me:5173/refresh',
returnUrl: 'http://test.lvh.me:5173/return',
});
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: paymentKeys.connectStatus() });
// Should NOT invalidate config on refresh (only on initial onboarding)
expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({ queryKey: paymentKeys.config() });
});
});
});

View File

@@ -0,0 +1,864 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock dependencies
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
},
}));
vi.mock('../../utils/cookies', () => ({
getCookie: vi.fn(),
}));
import { usePlanFeatures, FEATURE_NAMES, FEATURE_DESCRIPTIONS } from '../usePlanFeatures';
import apiClient from '../../api/client';
import { getCookie } from '../../utils/cookies';
import type { PlanPermissions } from '../../types';
// 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('usePlanFeatures', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when business data is loading', () => {
it('returns isLoading: true and safe defaults', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
// Don't resolve the promise yet to simulate loading state
vi.mocked(apiClient.get).mockImplementation(() => new Promise(() => {}));
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.plan).toBeUndefined();
expect(result.current.permissions).toBeUndefined();
expect(result.current.canUse('sms_reminders')).toBe(false);
});
});
describe('when no business data exists (no token)', () => {
it('returns false for all feature checks', async () => {
vi.mocked(getCookie).mockReturnValue(null);
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeUndefined();
expect(result.current.plan).toBeUndefined();
expect(result.current.permissions).toBeUndefined();
expect(result.current.canUse('sms_reminders')).toBe(false);
expect(result.current.canUse('webhooks')).toBe(false);
expect(result.current.canUse('api_access')).toBe(false);
});
});
describe('when business has no planPermissions', () => {
it('returns false for all feature checks', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Free',
// No plan_permissions field
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.plan).toBe('Free');
expect(result.current.permissions).toBeDefined();
expect(result.current.canUse('sms_reminders')).toBe(false);
expect(result.current.canUse('webhooks')).toBe(false);
expect(result.current.canUse('contracts')).toBe(false);
});
});
describe('canUse', () => {
it('returns true when feature is enabled in plan permissions', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
webhooks: true,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: true,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUse('sms_reminders')).toBe(true);
expect(result.current.canUse('webhooks')).toBe(true);
expect(result.current.canUse('export_data')).toBe(true);
});
it('returns false when feature is disabled in plan permissions', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Free',
plan_permissions: {
sms_reminders: false,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUse('sms_reminders')).toBe(false);
expect(result.current.canUse('webhooks')).toBe(false);
expect(result.current.canUse('custom_domain')).toBe(false);
});
it('returns false for undefined features (null coalescing)', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
// Missing other features
} as Partial<PlanPermissions>,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUse('sms_reminders')).toBe(true);
expect(result.current.canUse('webhooks')).toBe(false);
expect(result.current.canUse('api_access')).toBe(false);
});
it('handles all feature types', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Enterprise Business',
subdomain: 'enterprise',
tier: 'Enterprise',
plan_permissions: {
sms_reminders: true,
webhooks: true,
api_access: true,
custom_domain: true,
white_label: true,
custom_oauth: true,
plugins: true,
tasks: true,
export_data: true,
video_conferencing: true,
two_factor_auth: true,
masked_calling: true,
pos_system: true,
mobile_app: true,
contracts: true,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Test all features are accessible
expect(result.current.canUse('sms_reminders')).toBe(true);
expect(result.current.canUse('webhooks')).toBe(true);
expect(result.current.canUse('api_access')).toBe(true);
expect(result.current.canUse('custom_domain')).toBe(true);
expect(result.current.canUse('white_label')).toBe(true);
expect(result.current.canUse('custom_oauth')).toBe(true);
expect(result.current.canUse('plugins')).toBe(true);
expect(result.current.canUse('tasks')).toBe(true);
expect(result.current.canUse('export_data')).toBe(true);
expect(result.current.canUse('video_conferencing')).toBe(true);
expect(result.current.canUse('two_factor_auth')).toBe(true);
expect(result.current.canUse('masked_calling')).toBe(true);
expect(result.current.canUse('pos_system')).toBe(true);
expect(result.current.canUse('mobile_app')).toBe(true);
expect(result.current.canUse('contracts')).toBe(true);
});
});
describe('canUseAny', () => {
it('returns true when at least one feature is available', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAny(['sms_reminders', 'webhooks', 'api_access'])).toBe(true);
expect(result.current.canUseAny(['sms_reminders'])).toBe(true);
});
it('returns false when no features are available', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Free',
plan_permissions: {
sms_reminders: false,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAny(['webhooks', 'api_access', 'custom_domain'])).toBe(false);
});
it('handles empty array', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
webhooks: true,
api_access: true,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAny([])).toBe(false);
});
it('returns true when multiple features are available', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Business',
subdomain: 'biz',
tier: 'Business',
plan_permissions: {
sms_reminders: true,
webhooks: true,
api_access: true,
custom_domain: true,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: true,
video_conferencing: true,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAny(['sms_reminders', 'webhooks'])).toBe(true);
expect(result.current.canUseAny(['api_access', 'custom_domain', 'export_data'])).toBe(true);
});
});
describe('canUseAll', () => {
it('returns true when all features are available', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
webhooks: true,
api_access: true,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAll(['sms_reminders', 'webhooks', 'api_access'])).toBe(true);
expect(result.current.canUseAll(['sms_reminders'])).toBe(true);
});
it('returns false when any feature is unavailable', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
webhooks: false,
api_access: true,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAll(['sms_reminders', 'webhooks', 'api_access'])).toBe(false);
expect(result.current.canUseAll(['webhooks'])).toBe(false);
});
it('handles empty array', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Free',
plan_permissions: {
sms_reminders: false,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAll([])).toBe(true);
});
it('returns false when all features are unavailable', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Free',
plan_permissions: {
sms_reminders: false,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.canUseAll(['webhooks', 'api_access', 'custom_domain'])).toBe(false);
});
});
describe('plan property', () => {
it('returns the current plan tier', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.plan).toBe('Professional');
});
it('handles different plan tiers', async () => {
const plans = ['Free', 'Professional', 'Business', 'Enterprise'];
for (const tier of plans) {
vi.clearAllMocks();
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier,
plan_permissions: {
sms_reminders: false,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.plan).toBe(tier);
}
});
});
describe('permissions property', () => {
it('returns all plan permissions', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
const mockPermissions = {
sms_reminders: true,
webhooks: true,
api_access: false,
custom_domain: true,
white_label: false,
custom_oauth: false,
plugins: true,
tasks: true,
export_data: false,
video_conferencing: true,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: true,
contracts: false,
};
const mockBusiness = {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Business',
plan_permissions: mockPermissions,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockBusiness });
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.permissions).toEqual(mockPermissions);
});
});
describe('isLoading property', () => {
it('reflects the loading state of the business query', async () => {
vi.mocked(getCookie).mockReturnValue('valid-token');
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
vi.mocked(apiClient.get).mockReturnValue(promise as any);
const { result } = renderHook(() => usePlanFeatures(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
// Resolve the promise
resolvePromise!({
data: {
id: 1,
name: 'Test Business',
subdomain: 'test',
tier: 'Professional',
plan_permissions: {
sms_reminders: true,
webhooks: false,
api_access: false,
custom_domain: false,
white_label: false,
custom_oauth: false,
plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,
mobile_app: false,
contracts: false,
},
},
});
// Wait for loading to complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
});
});
});
describe('FEATURE_NAMES', () => {
it('contains all feature keys', () => {
const expectedFeatures = [
'sms_reminders',
'webhooks',
'api_access',
'custom_domain',
'white_label',
'custom_oauth',
'plugins',
'tasks',
'export_data',
'video_conferencing',
'two_factor_auth',
'masked_calling',
'pos_system',
'mobile_app',
'contracts',
];
expectedFeatures.forEach((feature) => {
expect(FEATURE_NAMES).toHaveProperty(feature);
expect(typeof FEATURE_NAMES[feature as keyof typeof FEATURE_NAMES]).toBe('string');
expect(FEATURE_NAMES[feature as keyof typeof FEATURE_NAMES].length).toBeGreaterThan(0);
});
});
it('has user-friendly display names', () => {
expect(FEATURE_NAMES.sms_reminders).toBe('SMS Reminders');
expect(FEATURE_NAMES.webhooks).toBe('Webhooks');
expect(FEATURE_NAMES.api_access).toBe('API Access');
expect(FEATURE_NAMES.custom_domain).toBe('Custom Domain');
expect(FEATURE_NAMES.white_label).toBe('White Label');
expect(FEATURE_NAMES.custom_oauth).toBe('Custom OAuth');
expect(FEATURE_NAMES.plugins).toBe('Custom Plugins');
expect(FEATURE_NAMES.tasks).toBe('Scheduled Tasks');
expect(FEATURE_NAMES.export_data).toBe('Data Export');
expect(FEATURE_NAMES.video_conferencing).toBe('Video Conferencing');
expect(FEATURE_NAMES.two_factor_auth).toBe('Two-Factor Authentication');
expect(FEATURE_NAMES.masked_calling).toBe('Masked Calling');
expect(FEATURE_NAMES.pos_system).toBe('POS System');
expect(FEATURE_NAMES.mobile_app).toBe('Mobile App');
expect(FEATURE_NAMES.contracts).toBe('Contracts');
});
});
describe('FEATURE_DESCRIPTIONS', () => {
it('contains all feature keys', () => {
const expectedFeatures = [
'sms_reminders',
'webhooks',
'api_access',
'custom_domain',
'white_label',
'custom_oauth',
'plugins',
'tasks',
'export_data',
'video_conferencing',
'two_factor_auth',
'masked_calling',
'pos_system',
'mobile_app',
'contracts',
];
expectedFeatures.forEach((feature) => {
expect(FEATURE_DESCRIPTIONS).toHaveProperty(feature);
expect(typeof FEATURE_DESCRIPTIONS[feature as keyof typeof FEATURE_DESCRIPTIONS]).toBe('string');
expect(FEATURE_DESCRIPTIONS[feature as keyof typeof FEATURE_DESCRIPTIONS].length).toBeGreaterThan(0);
});
});
it('has descriptive text for upgrade prompts', () => {
expect(FEATURE_DESCRIPTIONS.sms_reminders).toContain('SMS reminders');
expect(FEATURE_DESCRIPTIONS.webhooks).toContain('webhooks');
expect(FEATURE_DESCRIPTIONS.api_access).toContain('API');
expect(FEATURE_DESCRIPTIONS.custom_domain).toContain('custom domain');
expect(FEATURE_DESCRIPTIONS.white_label).toContain('branding');
expect(FEATURE_DESCRIPTIONS.custom_oauth).toContain('OAuth');
expect(FEATURE_DESCRIPTIONS.plugins).toContain('plugin');
expect(FEATURE_DESCRIPTIONS.tasks).toContain('task');
expect(FEATURE_DESCRIPTIONS.export_data).toContain('Export');
expect(FEATURE_DESCRIPTIONS.video_conferencing).toContain('video');
expect(FEATURE_DESCRIPTIONS.two_factor_auth).toContain('two-factor');
expect(FEATURE_DESCRIPTIONS.masked_calling).toContain('masked');
expect(FEATURE_DESCRIPTIONS.pos_system).toContain('Point of Sale');
expect(FEATURE_DESCRIPTIONS.mobile_app).toContain('mobile');
expect(FEATURE_DESCRIPTIONS.contracts).toContain('contract');
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,461 @@
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 profile API
vi.mock('../../api/profile', () => ({
getProfile: vi.fn(),
updateProfile: vi.fn(),
uploadAvatar: vi.fn(),
deleteAvatar: vi.fn(),
sendVerificationEmail: vi.fn(),
verifyEmail: vi.fn(),
requestEmailChange: vi.fn(),
confirmEmailChange: vi.fn(),
changePassword: vi.fn(),
setupTOTP: vi.fn(),
verifyTOTP: vi.fn(),
disableTOTP: vi.fn(),
getRecoveryCodes: vi.fn(),
regenerateRecoveryCodes: vi.fn(),
sendPhoneVerification: vi.fn(),
verifyPhoneCode: vi.fn(),
getSessions: vi.fn(),
revokeSession: vi.fn(),
revokeOtherSessions: vi.fn(),
getLoginHistory: vi.fn(),
getUserEmails: vi.fn(),
addUserEmail: vi.fn(),
deleteUserEmail: vi.fn(),
sendUserEmailVerification: vi.fn(),
verifyUserEmail: vi.fn(),
setPrimaryEmail: vi.fn(),
}));
import {
useProfile,
useUpdateProfile,
useUploadAvatar,
useDeleteAvatar,
useSendVerificationEmail,
useVerifyEmail,
useRequestEmailChange,
useConfirmEmailChange,
useChangePassword,
useSetupTOTP,
useVerifyTOTP,
useDisableTOTP,
useRegenerateRecoveryCodes,
useSendPhoneVerification,
useVerifyPhoneCode,
useSessions,
useRevokeSession,
useRevokeOtherSessions,
useLoginHistory,
useUserEmails,
useAddUserEmail,
useDeleteUserEmail,
useSendUserEmailVerification,
useVerifyUserEmail,
useSetPrimaryEmail,
} from '../useProfile';
import * as profileApi from '../../api/profile';
// 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('useProfile hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useProfile', () => {
it('fetches user profile', async () => {
const mockProfile = { id: 1, name: 'Test User', email: 'test@example.com' };
vi.mocked(profileApi.getProfile).mockResolvedValue(mockProfile as any);
const { result } = renderHook(() => useProfile(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockProfile);
});
});
describe('useUpdateProfile', () => {
it('updates profile', async () => {
const mockUpdated = { id: 1, name: 'Updated' };
vi.mocked(profileApi.updateProfile).mockResolvedValue(mockUpdated as any);
const { result } = renderHook(() => useUpdateProfile(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ name: 'Updated' });
});
expect(profileApi.updateProfile).toHaveBeenCalled();
});
});
describe('useUploadAvatar', () => {
it('uploads avatar', async () => {
vi.mocked(profileApi.uploadAvatar).mockResolvedValue({ avatar_url: 'url' });
const { result } = renderHook(() => useUploadAvatar(), {
wrapper: createWrapper(),
});
const file = new File(['test'], 'avatar.jpg');
await act(async () => {
await result.current.mutateAsync(file);
});
expect(profileApi.uploadAvatar).toHaveBeenCalled();
});
});
describe('useDeleteAvatar', () => {
it('deletes avatar', async () => {
vi.mocked(profileApi.deleteAvatar).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteAvatar(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(profileApi.deleteAvatar).toHaveBeenCalled();
});
});
describe('email hooks', () => {
it('sends verification email', async () => {
vi.mocked(profileApi.sendVerificationEmail).mockResolvedValue(undefined);
const { result } = renderHook(() => useSendVerificationEmail(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(profileApi.sendVerificationEmail).toHaveBeenCalled();
});
it('verifies email', async () => {
vi.mocked(profileApi.verifyEmail).mockResolvedValue(undefined);
const { result } = renderHook(() => useVerifyEmail(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('token');
});
expect(profileApi.verifyEmail).toHaveBeenCalled();
});
it('requests email change', async () => {
vi.mocked(profileApi.requestEmailChange).mockResolvedValue(undefined);
const { result } = renderHook(() => useRequestEmailChange(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('new@example.com');
});
expect(profileApi.requestEmailChange).toHaveBeenCalled();
});
it('confirms email change', async () => {
vi.mocked(profileApi.confirmEmailChange).mockResolvedValue(undefined);
const { result } = renderHook(() => useConfirmEmailChange(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('token');
});
expect(profileApi.confirmEmailChange).toHaveBeenCalled();
});
});
describe('useChangePassword', () => {
it('changes password', async () => {
vi.mocked(profileApi.changePassword).mockResolvedValue(undefined);
const { result } = renderHook(() => useChangePassword(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
currentPassword: 'old',
newPassword: 'new',
});
});
expect(profileApi.changePassword).toHaveBeenCalled();
});
});
describe('2FA hooks', () => {
it('sets up TOTP', async () => {
const mockSetup = { secret: 'ABC', qr_code: 'qr', provisioning_uri: 'uri' };
vi.mocked(profileApi.setupTOTP).mockResolvedValue(mockSetup);
const { result } = renderHook(() => useSetupTOTP(), {
wrapper: createWrapper(),
});
await act(async () => {
const data = await result.current.mutateAsync();
expect(data).toEqual(mockSetup);
});
});
it('verifies TOTP', async () => {
vi.mocked(profileApi.verifyTOTP).mockResolvedValue({ success: true, recovery_codes: [] });
const { result } = renderHook(() => useVerifyTOTP(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('123456');
});
expect(profileApi.verifyTOTP).toHaveBeenCalled();
});
it('disables TOTP', async () => {
vi.mocked(profileApi.disableTOTP).mockResolvedValue(undefined);
const { result } = renderHook(() => useDisableTOTP(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('123456');
});
expect(profileApi.disableTOTP).toHaveBeenCalled();
});
it('regenerates recovery codes', async () => {
vi.mocked(profileApi.regenerateRecoveryCodes).mockResolvedValue(['code1', 'code2']);
const { result } = renderHook(() => useRegenerateRecoveryCodes(), {
wrapper: createWrapper(),
});
await act(async () => {
const codes = await result.current.mutateAsync();
expect(codes).toEqual(['code1', 'code2']);
});
});
});
describe('phone hooks', () => {
it('sends phone verification', async () => {
vi.mocked(profileApi.sendPhoneVerification).mockResolvedValue(undefined);
const { result } = renderHook(() => useSendPhoneVerification(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('555-1234');
});
expect(profileApi.sendPhoneVerification).toHaveBeenCalled();
});
it('verifies phone code', async () => {
vi.mocked(profileApi.verifyPhoneCode).mockResolvedValue(undefined);
const { result } = renderHook(() => useVerifyPhoneCode(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('123456');
});
expect(profileApi.verifyPhoneCode).toHaveBeenCalled();
});
});
describe('session hooks', () => {
it('fetches sessions', async () => {
const mockSessions = [{ id: '1', device_info: 'Chrome' }];
vi.mocked(profileApi.getSessions).mockResolvedValue(mockSessions as any);
const { result } = renderHook(() => useSessions(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockSessions);
});
it('revokes session', async () => {
vi.mocked(profileApi.revokeSession).mockResolvedValue(undefined);
const { result } = renderHook(() => useRevokeSession(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('session-id');
});
expect(profileApi.revokeSession).toHaveBeenCalled();
});
it('revokes other sessions', async () => {
vi.mocked(profileApi.revokeOtherSessions).mockResolvedValue(undefined);
const { result } = renderHook(() => useRevokeOtherSessions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(profileApi.revokeOtherSessions).toHaveBeenCalled();
});
it('fetches login history', async () => {
const mockHistory = [{ id: '1', success: true }];
vi.mocked(profileApi.getLoginHistory).mockResolvedValue(mockHistory as any);
const { result } = renderHook(() => useLoginHistory(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockHistory);
});
});
describe('multiple email hooks', () => {
it('fetches user emails', async () => {
const mockEmails = [{ id: 1, email: 'test@example.com' }];
vi.mocked(profileApi.getUserEmails).mockResolvedValue(mockEmails as any);
const { result } = renderHook(() => useUserEmails(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockEmails);
});
it('adds user email', async () => {
vi.mocked(profileApi.addUserEmail).mockResolvedValue({ id: 2, email: 'new@example.com' } as any);
const { result } = renderHook(() => useAddUserEmail(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('new@example.com');
});
expect(profileApi.addUserEmail).toHaveBeenCalled();
});
it('deletes user email', async () => {
vi.mocked(profileApi.deleteUserEmail).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteUserEmail(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(profileApi.deleteUserEmail).toHaveBeenCalled();
});
it('sends user email verification', async () => {
vi.mocked(profileApi.sendUserEmailVerification).mockResolvedValue(undefined);
const { result } = renderHook(() => useSendUserEmailVerification(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(profileApi.sendUserEmailVerification).toHaveBeenCalled();
});
it('verifies user email', async () => {
vi.mocked(profileApi.verifyUserEmail).mockResolvedValue(undefined);
const { result } = renderHook(() => useVerifyUserEmail(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ emailId: 2, token: 'token' });
});
expect(profileApi.verifyUserEmail).toHaveBeenCalled();
});
it('sets primary email', async () => {
vi.mocked(profileApi.setPrimaryEmail).mockResolvedValue(undefined);
const { result } = renderHook(() => useSetPrimaryEmail(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(2);
});
expect(profileApi.setPrimaryEmail).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,561 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
},
}));
import { useResourceLocation, useLiveResourceLocation } from '../useResourceLocation';
import apiClient from '../../api/client';
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('useResourceLocation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('data transformation', () => {
it('should transform snake_case to camelCase for basic location data', async () => {
const mockResponse = {
data: {
has_location: true,
latitude: 40.7128,
longitude: -74.0060,
accuracy: 10,
heading: 180,
speed: 5.5,
timestamp: '2025-12-07T12:00:00Z',
is_tracking: true,
message: 'Location updated',
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
accuracy: 10,
heading: 180,
speed: 5.5,
timestamp: '2025-12-07T12:00:00Z',
isTracking: true,
activeJob: null,
message: 'Location updated',
});
});
it('should transform activeJob with status_display to statusDisplay', async () => {
const mockResponse = {
data: {
has_location: true,
latitude: 40.7128,
longitude: -74.0060,
is_tracking: true,
active_job: {
id: 456,
title: 'Repair HVAC System',
status: 'en_route',
status_display: 'En Route',
},
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.activeJob).toEqual({
id: 456,
title: 'Repair HVAC System',
status: 'en_route',
statusDisplay: 'En Route',
});
});
it('should set activeJob to null when not provided', async () => {
const mockResponse = {
data: {
has_location: false,
is_tracking: false,
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.activeJob).toBeNull();
});
it('should default isTracking to false when not provided', async () => {
const mockResponse = {
data: {
has_location: true,
latitude: 40.7128,
longitude: -74.0060,
// is_tracking not provided
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.isTracking).toBe(false);
});
it('should handle null active_job explicitly', async () => {
const mockResponse = {
data: {
has_location: true,
latitude: 40.7128,
longitude: -74.0060,
is_tracking: true,
active_job: null,
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.activeJob).toBeNull();
});
});
describe('API calls', () => {
it('should call the correct API endpoint with resourceId', async () => {
const mockResponse = {
data: {
has_location: false,
is_tracking: false,
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('789'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/resources/789/location/');
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
it('should not fetch when resourceId is null', () => {
const { result } = renderHook(() => useResourceLocation(null), {
wrapper: createWrapper(),
});
expect(result.current.isPending).toBe(true);
expect(result.current.fetchStatus).toBe('idle');
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should not fetch when enabled is false', () => {
const { result } = renderHook(
() => useResourceLocation('123', { enabled: false }),
{
wrapper: createWrapper(),
}
);
expect(result.current.isPending).toBe(true);
expect(result.current.fetchStatus).toBe('idle');
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should fetch when enabled is true', async () => {
const mockResponse = {
data: {
has_location: true,
is_tracking: true,
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(
() => useResourceLocation('123', { enabled: true }),
{
wrapper: createWrapper(),
}
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.get).toHaveBeenCalledWith('/resources/123/location/');
});
});
describe('error handling', () => {
it('should handle API errors', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
expect(result.current.data).toBeUndefined();
});
it('should handle 404 responses', async () => {
const mockError = {
response: {
status: 404,
data: { detail: 'Resource not found' },
},
};
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useResourceLocation('999'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(mockError);
});
});
describe('query configuration', () => {
it('should use the correct query key', async () => {
const mockResponse = {
data: {
has_location: false,
is_tracking: false,
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useResourceLocation('123'), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const cachedData = queryClient.getQueryData(['resourceLocation', '123']);
expect(cachedData).toBeDefined();
expect(cachedData).toEqual(result.current.data);
});
it('should not refetch automatically', async () => {
const mockResponse = {
data: {
has_location: true,
is_tracking: true,
},
};
vi.mocked(apiClient.get).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
// Wait a bit to ensure no automatic refetch
await new Promise(resolve => setTimeout(resolve, 100));
// Should only be called once (no refetchInterval)
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
});
describe('optional fields', () => {
it('should handle missing optional location fields', async () => {
const mockResponse = {
data: {
has_location: true,
latitude: 40.7128,
longitude: -74.0060,
is_tracking: true,
// accuracy, heading, speed, timestamp not provided
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
hasLocation: true,
latitude: 40.7128,
longitude: -74.0060,
accuracy: undefined,
heading: undefined,
speed: undefined,
timestamp: undefined,
isTracking: true,
activeJob: null,
message: undefined,
});
});
it('should handle message field when provided', async () => {
const mockResponse = {
data: {
has_location: false,
is_tracking: false,
message: 'Resource has not started tracking yet',
},
};
vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useResourceLocation('123'), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.message).toBe('Resource has not started tracking yet');
});
});
});
describe('useLiveResourceLocation', () => {
let mockWebSocket: {
close: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
removeEventListener: ReturnType<typeof vi.fn>;
readyState: number;
onopen: ((event: Event) => void) | null;
onmessage: ((event: MessageEvent) => void) | null;
onerror: ((event: Event) => void) | null;
onclose: ((event: CloseEvent) => void) | null;
};
beforeEach(() => {
vi.clearAllMocks();
// Mock WebSocket
mockWebSocket = {
close: vi.fn(),
send: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
readyState: 1, // OPEN
onopen: null,
onmessage: null,
onerror: null,
onclose: null,
};
// Mock WebSocket constructor properly
global.WebSocket = vi.fn(function(this: any) {
return mockWebSocket;
}) as any;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should not crash when rendered', () => {
const { result } = renderHook(() => useLiveResourceLocation('123'), {
wrapper: createWrapper(),
});
expect(result.current).toBeDefined();
expect(result.current.refresh).toBeInstanceOf(Function);
});
it('should create WebSocket connection with correct URL', () => {
renderHook(() => useLiveResourceLocation('456'), {
wrapper: createWrapper(),
});
expect(global.WebSocket).toHaveBeenCalledWith(
expect.stringContaining('/ws/resource-location/456/')
);
});
it('should not connect when resourceId is null', () => {
renderHook(() => useLiveResourceLocation(null), {
wrapper: createWrapper(),
});
expect(global.WebSocket).not.toHaveBeenCalled();
});
it('should not connect when enabled is false', () => {
renderHook(() => useLiveResourceLocation('123', { enabled: false }), {
wrapper: createWrapper(),
});
expect(global.WebSocket).not.toHaveBeenCalled();
});
it('should close WebSocket on unmount', () => {
const { unmount } = renderHook(() => useLiveResourceLocation('123'), {
wrapper: createWrapper(),
});
unmount();
expect(mockWebSocket.close).toHaveBeenCalledWith(1000, 'Component unmounting');
});
it('should return refresh function', () => {
const { result } = renderHook(() => useLiveResourceLocation('123'), {
wrapper: createWrapper(),
});
expect(result.current.refresh).toBeInstanceOf(Function);
// Should not throw when called
expect(() => result.current.refresh()).not.toThrow();
});
it('should handle location_update message type', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => useLiveResourceLocation('123'), { wrapper });
// Simulate WebSocket message
const mockMessage = {
data: JSON.stringify({
type: 'location_update',
latitude: 40.7128,
longitude: -74.0060,
accuracy: 10,
heading: 180,
speed: 5.5,
timestamp: '2025-12-07T12:00:00Z',
}),
};
if (mockWebSocket.onmessage) {
mockWebSocket.onmessage(mockMessage as MessageEvent);
}
// Verify query cache was updated
const cachedData = queryClient.getQueryData(['resourceLocation', '123']);
expect(cachedData).toBeDefined();
});
it('should handle tracking_stopped message type', () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
// Set initial data
queryClient.setQueryData(['resourceLocation', '123'], {
hasLocation: true,
isTracking: true,
latitude: 40.7128,
longitude: -74.0060,
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
renderHook(() => useLiveResourceLocation('123'), { wrapper });
// Simulate tracking stopped message
const mockMessage = {
data: JSON.stringify({
type: 'tracking_stopped',
}),
};
if (mockWebSocket.onmessage) {
mockWebSocket.onmessage(mockMessage as MessageEvent);
}
// Verify isTracking was set to false
const cachedData = queryClient.getQueryData<any>(['resourceLocation', '123']);
expect(cachedData?.isTracking).toBe(false);
});
it('should handle malformed WebSocket messages gracefully', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
renderHook(() => useLiveResourceLocation('123'), {
wrapper: createWrapper(),
});
// Simulate malformed JSON
const mockMessage = {
data: 'invalid json{{{',
};
if (mockWebSocket.onmessage) {
mockWebSocket.onmessage(mockMessage as MessageEvent);
}
// Should log error but not crash
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});

View File

@@ -0,0 +1,660 @@
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(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
useResourceTypes,
useCreateResourceType,
useUpdateResourceType,
useDeleteResourceType,
} from '../useResourceTypes';
import apiClient from '../../api/client';
import { ResourceTypeDefinition } from '../../types';
// 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('useResourceTypes hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useResourceTypes', () => {
it('fetches resource types successfully', async () => {
const mockResourceTypes: ResourceTypeDefinition[] = [
{
id: '1',
name: 'Stylist',
category: 'STAFF',
isDefault: false,
description: 'Hair stylist',
iconName: 'scissors',
},
{
id: '2',
name: 'Treatment Room',
category: 'OTHER',
isDefault: false,
description: 'Private treatment room',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourceTypes });
const { result } = renderHook(() => useResourceTypes(), {
wrapper: createWrapper(),
});
// Initially shows placeholder data
expect(result.current.data).toHaveLength(3);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
// Wait for the actual data to be set
expect(result.current.data).toEqual(mockResourceTypes);
});
expect(apiClient.get).toHaveBeenCalledWith('/resource-types/');
// After success, placeholderData is replaced with actual data
expect(result.current.data?.[0]).toEqual({
id: '1',
name: 'Stylist',
category: 'STAFF',
isDefault: false,
description: 'Hair stylist',
iconName: 'scissors',
});
expect(result.current.data?.[1]).toEqual({
id: '2',
name: 'Treatment Room',
category: 'OTHER',
isDefault: false,
description: 'Private treatment room',
});
});
it('returns placeholder data while loading', async () => {
vi.mocked(apiClient.get).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
const { result } = renderHook(() => useResourceTypes(), {
wrapper: createWrapper(),
});
// Should show placeholder data immediately
expect(result.current.data).toHaveLength(3);
expect(result.current.data?.[0]).toMatchObject({
id: 'default-staff',
name: 'Staff',
category: 'STAFF',
isDefault: true,
});
expect(result.current.data?.[1]).toMatchObject({
id: 'default-room',
name: 'Room',
category: 'OTHER',
isDefault: true,
});
expect(result.current.data?.[2]).toMatchObject({
id: 'default-equipment',
name: 'Equipment',
category: 'OTHER',
isDefault: true,
});
});
it('replaces placeholder data when API returns data', async () => {
const mockResourceTypes: ResourceTypeDefinition[] = [
{
id: '1',
name: 'Custom Type',
category: 'STAFF',
isDefault: false,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourceTypes });
const { result } = renderHook(() => useResourceTypes(), {
wrapper: createWrapper(),
});
// Initially shows placeholder data
expect(result.current.data).toHaveLength(3);
expect(result.current.isPlaceholderData).toBe(true);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
expect(result.current.isPlaceholderData).toBe(false);
});
// After success, should use actual data
expect(result.current.data).toEqual(mockResourceTypes);
expect(result.current.data).toHaveLength(1);
});
it('handles API errors gracefully', async () => {
const mockError = new Error('Network error');
vi.mocked(apiClient.get).mockRejectedValue(mockError);
const { result } = renderHook(() => useResourceTypes(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBe(mockError);
});
it('caches data with correct query key', async () => {
const mockResourceTypes: ResourceTypeDefinition[] = [
{
id: '1',
name: 'Staff',
category: 'STAFF',
isDefault: true,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourceTypes });
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result: result1 } = renderHook(() => useResourceTypes(), { wrapper });
await waitFor(() => {
expect(result1.current.isSuccess).toBe(true);
});
const callCountAfterFirst = vi.mocked(apiClient.get).mock.calls.length;
// Second call should use cached data without making another API call
const { result: result2 } = renderHook(() => useResourceTypes(), { wrapper });
// Wait for the hook to settle - it should use cached data immediately
await waitFor(() => {
expect(result2.current.data).toEqual(mockResourceTypes);
});
// Should not have made any additional API calls due to caching
expect(vi.mocked(apiClient.get).mock.calls.length).toBe(callCountAfterFirst);
});
});
describe('useCreateResourceType', () => {
it('creates a new resource type successfully', async () => {
const newResourceType = {
name: 'Massage Therapist',
category: 'STAFF' as const,
description: 'Licensed massage therapist',
iconName: 'hands',
};
const createdResourceType: ResourceTypeDefinition = {
id: '3',
...newResourceType,
isDefault: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: createdResourceType });
const { result } = renderHook(() => useCreateResourceType(), {
wrapper: createWrapper(),
});
let mutationResult: ResourceTypeDefinition | undefined;
await act(async () => {
mutationResult = await result.current.mutateAsync(newResourceType);
});
expect(apiClient.post).toHaveBeenCalledWith('/resource-types/', newResourceType);
expect(mutationResult).toEqual(createdResourceType);
});
it('creates resource type with minimal fields', async () => {
const newResourceType = {
name: 'Equipment',
category: 'OTHER' as const,
};
const createdResourceType: ResourceTypeDefinition = {
id: '4',
...newResourceType,
isDefault: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: createdResourceType });
const { result } = renderHook(() => useCreateResourceType(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(newResourceType);
});
expect(apiClient.post).toHaveBeenCalledWith('/resource-types/', newResourceType);
});
it('invalidates resource types cache on success', async () => {
const mockResourceTypes: ResourceTypeDefinition[] = [
{ id: '1', name: 'Staff', category: 'STAFF', isDefault: true },
];
const newResourceType = { name: 'New Type', category: 'OTHER' as const };
const createdResourceType: ResourceTypeDefinition = {
id: '2',
...newResourceType,
isDefault: false,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourceTypes });
vi.mocked(apiClient.post).mockResolvedValue({ data: createdResourceType });
const wrapper = createWrapper();
// First fetch resource types
const { result: queryResult } = renderHook(() => useResourceTypes(), { wrapper });
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledTimes(1);
// Create a new resource type
const { result: mutationResult } = renderHook(() => useCreateResourceType(), { wrapper });
await act(async () => {
await mutationResult.current.mutateAsync(newResourceType);
});
// Cache should be invalidated, triggering a refetch
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledTimes(2);
});
});
it('handles creation errors', async () => {
const mockError = new Error('Validation failed');
vi.mocked(apiClient.post).mockRejectedValue(mockError);
const { result } = renderHook(() => useCreateResourceType(), {
wrapper: createWrapper(),
});
let caughtError: Error | null = null;
await act(async () => {
try {
await result.current.mutateAsync({
name: 'Invalid',
category: 'STAFF',
});
} catch (error) {
caughtError = error as Error;
}
});
expect(caughtError).toBe(mockError);
// Wait for the mutation state to update
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
describe('useUpdateResourceType', () => {
it('updates a resource type successfully', async () => {
const updates = {
name: 'Senior Stylist',
description: 'Senior level hair stylist',
};
const updatedResourceType: ResourceTypeDefinition = {
id: '1',
...updates,
category: 'STAFF',
isDefault: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedResourceType });
const { result } = renderHook(() => useUpdateResourceType(), {
wrapper: createWrapper(),
});
let mutationResult: ResourceTypeDefinition | undefined;
await act(async () => {
mutationResult = await result.current.mutateAsync({
id: '1',
updates,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/resource-types/1/', updates);
expect(mutationResult).toEqual(updatedResourceType);
});
it('updates only specified fields', async () => {
const updates = { iconName: 'star' };
vi.mocked(apiClient.patch).mockResolvedValue({
data: {
id: '1',
name: 'Staff',
category: 'STAFF',
isDefault: true,
iconName: 'star',
},
});
const { result } = renderHook(() => useUpdateResourceType(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/resource-types/1/', updates);
});
it('invalidates resource types cache on success', async () => {
const mockResourceTypes: ResourceTypeDefinition[] = [
{ id: '1', name: 'Staff', category: 'STAFF', isDefault: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourceTypes });
vi.mocked(apiClient.patch).mockResolvedValue({
data: { id: '1', name: 'Updated Staff', category: 'STAFF', isDefault: true },
});
const wrapper = createWrapper();
// First fetch resource types
const { result: queryResult } = renderHook(() => useResourceTypes(), { wrapper });
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledTimes(1);
// Update a resource type
const { result: mutationResult } = renderHook(() => useUpdateResourceType(), { wrapper });
await act(async () => {
await mutationResult.current.mutateAsync({
id: '1',
updates: { name: 'Updated Staff' },
});
});
// Cache should be invalidated, triggering a refetch
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledTimes(2);
});
});
it('handles update errors', async () => {
const mockError = new Error('Not found');
vi.mocked(apiClient.patch).mockRejectedValue(mockError);
const { result } = renderHook(() => useUpdateResourceType(), {
wrapper: createWrapper(),
});
let caughtError: Error | null = null;
await act(async () => {
try {
await result.current.mutateAsync({
id: '999',
updates: { name: 'Does not exist' },
});
} catch (error) {
caughtError = error as Error;
}
});
expect(caughtError).toBe(mockError);
// Wait for the mutation state to update
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('can update category', async () => {
const updates = { category: 'OTHER' as const };
vi.mocked(apiClient.patch).mockResolvedValue({
data: {
id: '1',
name: 'Staff',
category: 'OTHER',
isDefault: false,
},
});
const { result } = renderHook(() => useUpdateResourceType(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/resource-types/1/', updates);
});
});
describe('useDeleteResourceType', () => {
it('deletes a resource type successfully', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteResourceType(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('5');
});
expect(apiClient.delete).toHaveBeenCalledWith('/resource-types/5/');
});
it('invalidates resource types cache on success', async () => {
const mockResourceTypes: ResourceTypeDefinition[] = [
{ id: '1', name: 'Staff', category: 'STAFF', isDefault: true },
{ id: '2', name: 'Room', category: 'OTHER', isDefault: false },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResourceTypes });
vi.mocked(apiClient.delete).mockResolvedValue({});
const wrapper = createWrapper();
// First fetch resource types
const { result: queryResult } = renderHook(() => useResourceTypes(), { wrapper });
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledTimes(1);
// Delete a resource type
const { result: mutationResult } = renderHook(() => useDeleteResourceType(), { wrapper });
await act(async () => {
await mutationResult.current.mutateAsync('2');
});
// Cache should be invalidated, triggering a refetch
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledTimes(2);
});
});
it('handles deletion errors', async () => {
const mockError = new Error('Cannot delete default resource type');
vi.mocked(apiClient.delete).mockRejectedValue(mockError);
const { result } = renderHook(() => useDeleteResourceType(), {
wrapper: createWrapper(),
});
let caughtError: Error | null = null;
await act(async () => {
try {
await result.current.mutateAsync('default-staff');
} catch (error) {
caughtError = error as Error;
}
});
expect(caughtError).toBe(mockError);
// Wait for the mutation state to update
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('deletes by string id', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteResourceType(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('abc-123-def-456');
});
expect(apiClient.delete).toHaveBeenCalledWith('/resource-types/abc-123-def-456/');
});
});
describe('Integration tests', () => {
it('supports full CRUD workflow', async () => {
const wrapper = createWrapper();
// 1. Fetch initial resource types
vi.mocked(apiClient.get).mockResolvedValue({
data: [
{ id: '1', name: 'Staff', category: 'STAFF', isDefault: true },
],
});
const { result: queryResult } = renderHook(() => useResourceTypes(), { wrapper });
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
expect(queryResult.current.data).toHaveLength(1);
});
// 2. Create new resource type
const newType = { name: 'Therapist', category: 'STAFF' as const };
vi.mocked(apiClient.post).mockResolvedValue({
data: { id: '2', ...newType, isDefault: false },
});
const { result: createResult } = renderHook(() => useCreateResourceType(), { wrapper });
await act(async () => {
await createResult.current.mutateAsync(newType);
});
// 3. Update the created resource type
vi.mocked(apiClient.patch).mockResolvedValue({
data: { id: '2', name: 'Senior Therapist', category: 'STAFF', isDefault: false },
});
const { result: updateResult } = renderHook(() => useUpdateResourceType(), { wrapper });
await act(async () => {
await updateResult.current.mutateAsync({
id: '2',
updates: { name: 'Senior Therapist' },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/resource-types/2/', {
name: 'Senior Therapist',
});
// 4. Delete the resource type
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result: deleteResult } = renderHook(() => useDeleteResourceType(), { wrapper });
await act(async () => {
await deleteResult.current.mutateAsync('2');
});
expect(apiClient.delete).toHaveBeenCalledWith('/resource-types/2/');
});
it('handles concurrent mutations correctly', async () => {
const wrapper = createWrapper();
vi.mocked(apiClient.post).mockResolvedValue({
data: { id: '1', name: 'Type 1', category: 'STAFF', isDefault: false },
});
const { result: createResult1 } = renderHook(() => useCreateResourceType(), { wrapper });
const { result: createResult2 } = renderHook(() => useCreateResourceType(), { wrapper });
await act(async () => {
await Promise.all([
createResult1.current.mutateAsync({ name: 'Type 1', category: 'STAFF' }),
createResult2.current.mutateAsync({ name: 'Type 2', category: 'OTHER' }),
]);
});
expect(apiClient.post).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,242 @@
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(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
useResources,
useResource,
useCreateResource,
useUpdateResource,
useDeleteResource,
} from '../useResources';
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('useResources hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useResources', () => {
it('fetches resources and transforms data', async () => {
const mockResources = [
{ id: 1, name: 'Room 1', type: 'ROOM', max_concurrent_events: 2 },
{ id: 2, name: 'Staff 1', type: 'STAFF', user_id: 10 },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResources });
const { result } = renderHook(() => useResources(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/resources/?');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual({
id: '1',
name: 'Room 1',
type: 'ROOM',
userId: undefined,
maxConcurrentEvents: 2,
savedLaneCount: undefined,
userCanEditSchedule: false,
});
});
it('applies type filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useResources({ type: 'STAFF' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/resources/?type=STAFF');
});
});
});
describe('useResource', () => {
it('fetches single resource by id', async () => {
const mockResource = {
id: 1,
name: 'Room 1',
type: 'ROOM',
max_concurrent_events: 1,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResource });
const { result } = renderHook(() => useResource('1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/resources/1/');
expect(result.current.data?.name).toBe('Room 1');
});
it('does not fetch when id is empty', async () => {
const { result } = renderHook(() => useResource(''), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(apiClient.get).not.toHaveBeenCalled();
});
});
describe('useCreateResource', () => {
it('creates resource with backend field mapping', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'New Room',
type: 'ROOM',
maxConcurrentEvents: 3,
});
});
expect(apiClient.post).toHaveBeenCalledWith('/resources/', {
name: 'New Room',
type: 'ROOM',
user: null,
timezone: 'UTC',
max_concurrent_events: 3,
});
});
it('converts userId to user integer', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'Staff',
type: 'STAFF',
userId: '42',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/resources/', expect.objectContaining({
user: 42,
}));
});
});
describe('useUpdateResource', () => {
it('updates resource with mapped fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useUpdateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: { name: 'Updated Room', maxConcurrentEvents: 5 },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/resources/1/', {
name: 'Updated Room',
max_concurrent_events: 5,
});
});
it('handles userId update', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: { userId: '10' },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/resources/1/', {
user: 10,
});
});
it('sets user to null when userId is empty', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: { userId: '' },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/resources/1/', {
user: null,
});
});
});
describe('useDeleteResource', () => {
it('deletes resource by id', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteResource(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('5');
});
expect(apiClient.delete).toHaveBeenCalledWith('/resources/5/');
});
});
});

View File

@@ -0,0 +1,579 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock the sandbox API
vi.mock('../../api/sandbox', () => ({
getSandboxStatus: vi.fn(),
toggleSandboxMode: vi.fn(),
resetSandboxData: vi.fn(),
}));
import {
useSandboxStatus,
useToggleSandbox,
useResetSandbox,
} from '../useSandbox';
import * as sandboxApi from '../../api/sandbox';
// 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('useSandbox hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useSandboxStatus', () => {
it('fetches sandbox status', async () => {
const mockStatus = {
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(sandboxApi.getSandboxStatus).toHaveBeenCalled();
expect(result.current.data).toEqual(mockStatus);
});
it('returns sandbox_mode as false when in live mode', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.sandbox_mode).toBe(false);
});
it('handles sandbox not being enabled for business', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: false,
sandbox_schema: null,
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.sandbox_enabled).toBe(false);
expect(result.current.data?.sandbox_schema).toBeNull();
});
it('handles API errors', async () => {
vi.mocked(sandboxApi.getSandboxStatus).mockRejectedValue(
new Error('Failed to fetch sandbox status')
);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
expect(result.current.error?.message).toBe('Failed to fetch sandbox status');
});
it('configures staleTime to 30 seconds', async () => {
const mockStatus = {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.getSandboxStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useSandboxStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Data should be considered fresh for 30 seconds
// This is configured in the hook with staleTime: 30 * 1000
expect(result.current.isStale).toBe(false);
});
});
describe('useToggleSandbox', () => {
let originalLocation: Location;
let reloadMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Mock window.location.reload
originalLocation = window.location;
reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
value: { ...originalLocation, reload: reloadMock },
writable: true,
});
});
afterEach(() => {
// Restore window.location
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
});
});
it('toggles sandbox mode to enabled', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(true);
});
expect(sandboxApi.toggleSandboxMode).toHaveBeenCalled();
expect(vi.mocked(sandboxApi.toggleSandboxMode).mock.calls[0][0]).toBe(true);
});
it('toggles sandbox mode to disabled', async () => {
const mockResponse = {
sandbox_mode: false,
message: 'Sandbox mode disabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(false);
});
expect(sandboxApi.toggleSandboxMode).toHaveBeenCalled();
expect(vi.mocked(sandboxApi.toggleSandboxMode).mock.calls[0][0]).toBe(false);
});
it('updates sandbox status in cache on success', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const wrapper = createWrapper();
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
// Pre-populate cache with initial status
queryClient.setQueryData(['sandboxStatus'], {
sandbox_mode: false,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
});
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync(true);
});
// Verify cache was updated
const cachedData = queryClient.getQueryData(['sandboxStatus']);
expect(cachedData).toEqual({
sandbox_mode: true,
sandbox_enabled: true,
sandbox_schema: 'business_1_sandbox',
});
});
it('reloads window after successful toggle', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(true);
});
expect(reloadMock).toHaveBeenCalled();
});
it('handles toggle errors without reloading', async () => {
vi.mocked(sandboxApi.toggleSandboxMode).mockRejectedValue(
new Error('Failed to toggle sandbox mode')
);
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync(true);
} catch (error) {
// Expected to throw
}
});
expect(reloadMock).not.toHaveBeenCalled();
});
it('updates cache even when old data is undefined', async () => {
const mockResponse = {
sandbox_mode: true,
message: 'Sandbox mode enabled',
};
vi.mocked(sandboxApi.toggleSandboxMode).mockResolvedValue(mockResponse);
const wrapper = createWrapper();
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useToggleSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync(true);
});
// Verify cache was set even with no prior data
const cachedData = queryClient.getQueryData(['sandboxStatus']);
expect(cachedData).toMatchObject({
sandbox_mode: true,
});
});
});
describe('useResetSandbox', () => {
it('resets sandbox data', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useResetSandbox(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync();
});
expect(sandboxApi.resetSandboxData).toHaveBeenCalled();
});
it('invalidates resource queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['resources'] });
});
it('invalidates event queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['events'] });
});
it('invalidates service queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['services'] });
});
it('invalidates customer queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['customers'] });
});
it('invalidates payment queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['payments'] });
});
it('invalidates all required queries on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
await result.current.mutateAsync();
});
// Verify all expected queries were invalidated
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['resources'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['events'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['services'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['customers'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['payments'] });
expect(invalidateSpy).toHaveBeenCalledTimes(5);
});
it('handles reset errors', async () => {
vi.mocked(sandboxApi.resetSandboxData).mockRejectedValue(
new Error('Failed to reset sandbox data')
);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const CustomWrapper = ({ children }: { children: React.ReactNode }) => {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
const { result } = renderHook(() => useResetSandbox(), {
wrapper: CustomWrapper,
});
await act(async () => {
try {
await result.current.mutateAsync();
} catch (error) {
// Expected to throw
}
});
// Verify queries were NOT invalidated on error
expect(invalidateSpy).not.toHaveBeenCalled();
});
it('returns response data on success', async () => {
const mockResponse = {
message: 'Sandbox data reset successfully',
sandbox_schema: 'business_1_sandbox',
};
vi.mocked(sandboxApi.resetSandboxData).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useResetSandbox(), {
wrapper: createWrapper(),
});
let response;
await act(async () => {
response = await result.current.mutateAsync();
});
expect(response).toEqual(mockResponse);
});
});
});

View File

@@ -0,0 +1,238 @@
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(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
useServices,
useService,
useCreateService,
useUpdateService,
useDeleteService,
useReorderServices,
} from '../useServices';
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('useServices hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useServices', () => {
it('fetches services and transforms data', async () => {
const mockServices = [
{ id: 1, name: 'Haircut', duration: 30, price: '25.00', description: 'Basic haircut' },
{ id: 2, name: 'Color', duration_minutes: 60, price: '75.00', variable_pricing: true },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServices });
const { result } = renderHook(() => useServices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/services/');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual(expect.objectContaining({
id: '1',
name: 'Haircut',
durationMinutes: 30,
price: 25,
description: 'Basic haircut',
}));
expect(result.current.data?.[1].variable_pricing).toBe(true);
});
it('handles missing fields with defaults', async () => {
const mockServices = [
{ id: 1, name: 'Service', price: '10.00', duration: 15 },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockServices });
const { result } = renderHook(() => useServices(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].description).toBe('');
expect(result.current.data?.[0].displayOrder).toBe(0);
expect(result.current.data?.[0].photos).toEqual([]);
});
});
describe('useService', () => {
it('fetches single service by id', async () => {
const mockService = {
id: 1,
name: 'Premium Cut',
duration: 45,
price: '50.00',
description: 'Premium service',
photos: ['photo1.jpg'],
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockService });
const { result } = renderHook(() => useService('1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/services/1/');
expect(result.current.data?.name).toBe('Premium Cut');
});
it('does not fetch when id is empty', async () => {
const { result } = renderHook(() => useService(''), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(apiClient.get).not.toHaveBeenCalled();
});
});
describe('useCreateService', () => {
it('creates service with correct field mapping', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateService(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'New Service',
durationMinutes: 45,
price: 35.99,
description: 'Test description',
photos: ['photo.jpg'],
});
});
expect(apiClient.post).toHaveBeenCalledWith('/services/', {
name: 'New Service',
duration: 45,
price: '35.99',
description: 'Test description',
photos: ['photo.jpg'],
});
});
it('includes pricing fields when provided', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateService(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'Priced Service',
durationMinutes: 30,
price: 100,
variable_pricing: true,
deposit_amount: 25,
deposit_percent: 25,
});
});
expect(apiClient.post).toHaveBeenCalledWith('/services/', expect.objectContaining({
variable_pricing: true,
deposit_amount: 25,
deposit_percent: 25,
}));
});
});
describe('useUpdateService', () => {
it('updates service with mapped fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateService(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: { name: 'Updated Name', price: 50 },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/services/1/', {
name: 'Updated Name',
price: '50',
});
});
});
describe('useDeleteService', () => {
it('deletes service by id', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteService(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('3');
});
expect(apiClient.delete).toHaveBeenCalledWith('/services/3/');
});
});
describe('useReorderServices', () => {
it('sends reorder request with converted ids', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useReorderServices(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(['3', '1', '2']);
});
expect(apiClient.post).toHaveBeenCalledWith('/services/reorder/', {
order: [3, 1, 2],
});
});
});
});

View File

@@ -0,0 +1,522 @@
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(),
patch: vi.fn(),
},
}));
import {
useStaff,
useUpdateStaff,
useToggleStaffActive,
} from '../useStaff';
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('useStaff hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useStaff', () => {
it('fetches staff and transforms data correctly', async () => {
const mockStaff = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone: '555-1234',
role: 'TENANT_MANAGER',
is_active: true,
permissions: { can_invite_staff: true },
can_invite_staff: true,
},
{
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
phone: '555-5678',
role: 'TENANT_STAFF',
is_active: false,
permissions: {},
can_invite_staff: false,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/staff/?show_inactive=true');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual({
id: '1',
name: 'John Doe',
email: 'john@example.com',
phone: '555-1234',
role: 'TENANT_MANAGER',
is_active: true,
permissions: { can_invite_staff: true },
can_invite_staff: true,
});
expect(result.current.data?.[1]).toEqual({
id: '2',
name: 'Jane Smith',
email: 'jane@example.com',
phone: '555-5678',
role: 'TENANT_STAFF',
is_active: false,
permissions: {},
can_invite_staff: false,
});
});
it('applies search filter', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useStaff({ search: 'john' }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/staff/?search=john&show_inactive=true');
});
});
it('transforms name from first_name and last_name when name is missing', async () => {
const mockStaff = [
{
id: 1,
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
role: 'TENANT_STAFF',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].name).toBe('John Doe');
});
it('falls back to email when name and first/last name are missing', async () => {
const mockStaff = [
{
id: 1,
email: 'john@example.com',
role: 'TENANT_STAFF',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].name).toBe('john@example.com');
});
it('handles partial first/last name correctly', async () => {
const mockStaff = [
{
id: 1,
first_name: 'John',
email: 'john@example.com',
role: 'TENANT_STAFF',
},
{
id: 2,
last_name: 'Smith',
email: 'smith@example.com',
role: 'TENANT_STAFF',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].name).toBe('John');
expect(result.current.data?.[1].name).toBe('Smith');
});
it('defaults is_active to true when missing', async () => {
const mockStaff = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'TENANT_STAFF',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].is_active).toBe(true);
});
it('defaults can_invite_staff to false when missing', async () => {
const mockStaff = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'TENANT_STAFF',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].can_invite_staff).toBe(false);
});
it('handles empty phone and sets defaults for missing fields', async () => {
const mockStaff = [
{
id: 1,
email: 'john@example.com',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0]).toEqual({
id: '1',
name: 'john@example.com',
email: 'john@example.com',
phone: '',
role: 'staff',
is_active: true,
permissions: {},
can_invite_staff: false,
});
});
it('converts id to string', async () => {
const mockStaff = [
{
id: 123,
name: 'John Doe',
email: 'john@example.com',
role: 'TENANT_STAFF',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].id).toBe('123');
expect(typeof result.current.data?.[0].id).toBe('string');
});
it('does not retry on failure', async () => {
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useStaff(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// Should only be called once (no retries)
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
});
describe('useUpdateStaff', () => {
it('updates staff member with is_active', async () => {
const mockResponse = {
id: 1,
is_active: false,
permissions: {},
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const { result } = renderHook(() => useUpdateStaff(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: { is_active: false },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/1/', {
is_active: false,
});
});
it('updates staff member with permissions', async () => {
const mockResponse = {
id: 1,
permissions: { can_invite_staff: true },
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const { result } = renderHook(() => useUpdateStaff(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '2',
updates: { permissions: { can_invite_staff: true } },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/2/', {
permissions: { can_invite_staff: true },
});
});
it('updates staff member with both is_active and permissions', async () => {
const mockResponse = {
id: 1,
is_active: true,
permissions: { can_invite_staff: false },
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const { result } = renderHook(() => useUpdateStaff(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: '3',
updates: {
is_active: true,
permissions: { can_invite_staff: false },
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/3/', {
is_active: true,
permissions: { can_invite_staff: false },
});
});
it('invalidates staff queries on success', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUpdateStaff(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
id: '1',
updates: { is_active: false },
});
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['staff'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['businessUsers'] });
});
it('returns response data', async () => {
const mockResponse = {
id: 1,
name: 'John Doe',
is_active: false,
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: mockResponse });
const { result } = renderHook(() => useUpdateStaff(), {
wrapper: createWrapper(),
});
let responseData;
await act(async () => {
responseData = await result.current.mutateAsync({
id: '1',
updates: { is_active: false },
});
});
expect(responseData).toEqual(mockResponse);
});
});
describe('useToggleStaffActive', () => {
it('toggles staff member active status', async () => {
const mockResponse = {
id: 1,
is_active: false,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const { result } = renderHook(() => useToggleStaffActive(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('1');
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/1/toggle_active/');
});
it('accepts string id', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useToggleStaffActive(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync('42');
});
expect(apiClient.post).toHaveBeenCalledWith('/staff/42/toggle_active/');
});
it('invalidates staff queries on success', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: {} });
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useToggleStaffActive(), { wrapper });
await act(async () => {
await result.current.mutateAsync('1');
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['staff'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['businessUsers'] });
});
it('returns response data', async () => {
const mockResponse = {
id: 1,
name: 'John Doe',
is_active: true,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
const { result } = renderHook(() => useToggleStaffActive(), {
wrapper: createWrapper(),
});
let responseData;
await act(async () => {
responseData = await result.current.mutateAsync('1');
});
expect(responseData).toEqual(mockResponse);
});
it('handles API errors', async () => {
const errorMessage = 'Staff member not found';
vi.mocked(apiClient.post).mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useToggleStaffActive(), {
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);
expect(apiClient.post).toHaveBeenCalledWith('/staff/999/toggle_active/');
});
});
});

View File

@@ -0,0 +1,842 @@
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 the ticket email addresses API module
vi.mock('../../api/ticketEmailAddresses', () => ({
getTicketEmailAddresses: vi.fn(),
getTicketEmailAddress: vi.fn(),
createTicketEmailAddress: vi.fn(),
updateTicketEmailAddress: vi.fn(),
deleteTicketEmailAddress: vi.fn(),
testImapConnection: vi.fn(),
testSmtpConnection: vi.fn(),
fetchEmailsNow: vi.fn(),
setAsDefault: vi.fn(),
}));
import {
useTicketEmailAddresses,
useTicketEmailAddress,
useCreateTicketEmailAddress,
useUpdateTicketEmailAddress,
useDeleteTicketEmailAddress,
useTestImapConnection,
useTestSmtpConnection,
useFetchEmailsNow,
useSetAsDefault,
} from '../useTicketEmailAddresses';
import * as ticketEmailAddressesApi from '../../api/ticketEmailAddresses';
// Create wrapper with QueryClient
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('useTicketEmailAddresses hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useTicketEmailAddresses', () => {
it('fetches all ticket email addresses', async () => {
const mockAddresses = [
{
id: 1,
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
},
{
id: 2,
display_name: 'Sales',
email_address: 'sales@example.com',
color: '#33A1FF',
is_active: true,
is_default: false,
last_check_at: null,
emails_processed_count: 0,
created_at: '2025-12-02T10:00:00Z',
updated_at: '2025-12-02T10:00:00Z',
},
];
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddresses).mockResolvedValue(
mockAddresses as any
);
const { result } = renderHook(() => useTicketEmailAddresses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketEmailAddressesApi.getTicketEmailAddresses).toHaveBeenCalled();
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual(mockAddresses[0]);
expect(result.current.data?.[1]).toEqual(mockAddresses[1]);
});
it('handles empty list', async () => {
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddresses).mockResolvedValue([]);
const { result } = renderHook(() => useTicketEmailAddresses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('handles API errors', async () => {
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddresses).mockRejectedValue(
new Error('API Error')
);
const { result } = renderHook(() => useTicketEmailAddresses(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
});
});
describe('useTicketEmailAddress', () => {
it('fetches single ticket email address by id', async () => {
const mockAddress = {
id: 1,
tenant: 5,
tenant_name: 'Example Business',
display_name: 'Support',
email_address: 'support@example.com',
color: '#FF5733',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'support@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'support@example.com',
is_active: true,
is_default: true,
last_check_at: '2025-12-07T10:00:00Z',
last_error: null,
emails_processed_count: 42,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddress).mockResolvedValue(
mockAddress as any
);
const { result } = renderHook(() => useTicketEmailAddress(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(ticketEmailAddressesApi.getTicketEmailAddress).toHaveBeenCalledWith(1);
expect(result.current.data).toEqual(mockAddress);
});
it('does not fetch when id is 0', async () => {
const { result } = renderHook(() => useTicketEmailAddress(0), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(ticketEmailAddressesApi.getTicketEmailAddress).not.toHaveBeenCalled();
});
it('handles API errors', async () => {
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddress).mockRejectedValue(
new Error('Not found')
);
const { result } = renderHook(() => useTicketEmailAddress(999), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeInstanceOf(Error);
});
it('handles addresses with last_error', async () => {
const mockAddress = {
id: 3,
tenant: 5,
tenant_name: 'Example Business',
display_name: 'Broken Email',
email_address: 'broken@example.com',
color: '#FF0000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'broken@example.com',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'broken@example.com',
is_active: true,
is_default: false,
last_check_at: '2025-12-07T10:00:00Z',
last_error: 'Authentication failed',
emails_processed_count: 0,
created_at: '2025-12-01T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
is_imap_configured: true,
is_smtp_configured: true,
is_fully_configured: true,
};
vi.mocked(ticketEmailAddressesApi.getTicketEmailAddress).mockResolvedValue(
mockAddress as any
);
const { result } = renderHook(() => useTicketEmailAddress(3), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.last_error).toBe('Authentication failed');
});
});
describe('useCreateTicketEmailAddress', () => {
it('creates a new ticket email address', async () => {
const newAddress = {
display_name: 'Info',
email_address: 'info@example.com',
color: '#00FF00',
imap_host: 'imap.gmail.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'info@example.com',
imap_password: 'password123',
imap_folder: 'INBOX',
smtp_host: 'smtp.gmail.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'info@example.com',
smtp_password: 'password123',
is_active: true,
is_default: false,
};
const mockResponse = { id: 10, ...newAddress };
vi.mocked(ticketEmailAddressesApi.createTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const { result } = renderHook(() => useCreateTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(newAddress);
});
expect(ticketEmailAddressesApi.createTicketEmailAddress).toHaveBeenCalledWith(newAddress);
});
it('invalidates query cache on success', async () => {
const newAddress = {
display_name: 'Test',
email_address: 'test@example.com',
color: '#0000FF',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'test@example.com',
imap_password: 'pass',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'test@example.com',
smtp_password: 'pass',
is_active: true,
is_default: false,
};
const mockResponse = { id: 11, ...newAddress };
vi.mocked(ticketEmailAddressesApi.createTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useCreateTicketEmailAddress(), { wrapper });
await act(async () => {
await result.current.mutateAsync(newAddress);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
});
it('handles creation errors', async () => {
const newAddress = {
display_name: 'Error',
email_address: 'error@example.com',
color: '#FF0000',
imap_host: 'imap.example.com',
imap_port: 993,
imap_use_ssl: true,
imap_username: 'error@example.com',
imap_password: 'pass',
imap_folder: 'INBOX',
smtp_host: 'smtp.example.com',
smtp_port: 587,
smtp_use_tls: true,
smtp_use_ssl: false,
smtp_username: 'error@example.com',
smtp_password: 'pass',
is_active: true,
is_default: false,
};
vi.mocked(ticketEmailAddressesApi.createTicketEmailAddress).mockRejectedValue(
new Error('Validation error')
);
const { result } = renderHook(() => useCreateTicketEmailAddress(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(newAddress);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useUpdateTicketEmailAddress', () => {
it('updates an existing ticket email address', async () => {
const updates = {
display_name: 'Updated Support',
color: '#FF00FF',
};
const mockResponse = { id: 1, ...updates };
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const { result } = renderHook(() => useUpdateTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 1, data: updates });
});
expect(ticketEmailAddressesApi.updateTicketEmailAddress).toHaveBeenCalledWith(1, updates);
});
it('updates email configuration', async () => {
const updates = {
imap_host: 'imap.newserver.com',
imap_port: 993,
imap_password: 'newpassword',
};
const mockResponse = { id: 2, ...updates };
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const { result } = renderHook(() => useUpdateTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 2, data: updates });
});
expect(ticketEmailAddressesApi.updateTicketEmailAddress).toHaveBeenCalledWith(2, updates);
});
it('invalidates queries on success', async () => {
const updates = { display_name: 'New Name' };
const mockResponse = { id: 3, ...updates };
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).mockResolvedValue(
mockResponse as any
);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUpdateTicketEmailAddress(), { wrapper });
await act(async () => {
await result.current.mutateAsync({ id: 3, data: updates });
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses', 3] });
});
it('handles update errors', async () => {
vi.mocked(ticketEmailAddressesApi.updateTicketEmailAddress).mockRejectedValue(
new Error('Update failed')
);
const { result } = renderHook(() => useUpdateTicketEmailAddress(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync({ id: 999, data: { display_name: 'Test' } });
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useDeleteTicketEmailAddress', () => {
it('deletes a ticket email address', async () => {
vi.mocked(ticketEmailAddressesApi.deleteTicketEmailAddress).mockResolvedValue(undefined);
const { result } = renderHook(() => useDeleteTicketEmailAddress(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(5);
});
expect(ticketEmailAddressesApi.deleteTicketEmailAddress).toHaveBeenCalledWith(5);
});
it('invalidates query cache on success', async () => {
vi.mocked(ticketEmailAddressesApi.deleteTicketEmailAddress).mockResolvedValue(undefined);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useDeleteTicketEmailAddress(), { wrapper });
await act(async () => {
await result.current.mutateAsync(6);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
});
it('handles deletion errors', async () => {
vi.mocked(ticketEmailAddressesApi.deleteTicketEmailAddress).mockRejectedValue(
new Error('Cannot delete default address')
);
const { result } = renderHook(() => useDeleteTicketEmailAddress(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(1);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useTestImapConnection', () => {
it('tests IMAP connection successfully', async () => {
const mockResponse = {
success: true,
message: 'IMAP connection successful',
};
vi.mocked(ticketEmailAddressesApi.testImapConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestImapConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.testImapConnection).toHaveBeenCalledWith(1);
});
it('handles IMAP connection failure', async () => {
const mockResponse = {
success: false,
message: 'Authentication failed',
};
vi.mocked(ticketEmailAddressesApi.testImapConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestImapConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(2);
expect(response.success).toBe(false);
expect(response.message).toBe('Authentication failed');
});
});
it('handles API errors during IMAP test', async () => {
vi.mocked(ticketEmailAddressesApi.testImapConnection).mockRejectedValue(
new Error('Network error')
);
const { result } = renderHook(() => useTestImapConnection(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(3);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useTestSmtpConnection', () => {
it('tests SMTP connection successfully', async () => {
const mockResponse = {
success: true,
message: 'SMTP connection successful',
};
vi.mocked(ticketEmailAddressesApi.testSmtpConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestSmtpConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.testSmtpConnection).toHaveBeenCalledWith(1);
});
it('handles SMTP connection failure', async () => {
const mockResponse = {
success: false,
message: 'Could not connect to SMTP server',
};
vi.mocked(ticketEmailAddressesApi.testSmtpConnection).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useTestSmtpConnection(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(2);
expect(response.success).toBe(false);
expect(response.message).toBe('Could not connect to SMTP server');
});
});
it('handles API errors during SMTP test', async () => {
vi.mocked(ticketEmailAddressesApi.testSmtpConnection).mockRejectedValue(
new Error('Timeout')
);
const { result } = renderHook(() => useTestSmtpConnection(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(3);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useFetchEmailsNow', () => {
it('fetches emails successfully', async () => {
const mockResponse = {
success: true,
message: 'Successfully fetched 5 new emails',
processed: 5,
errors: 0,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.fetchEmailsNow).toHaveBeenCalledWith(1);
});
it('handles no new emails', async () => {
const mockResponse = {
success: true,
message: 'No new emails',
processed: 0,
errors: 0,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(2);
expect(response.processed).toBe(0);
});
});
it('handles errors during email fetch', async () => {
const mockResponse = {
success: false,
message: 'Failed to fetch emails',
processed: 2,
errors: 3,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(3);
expect(response.success).toBe(false);
expect(response.errors).toBe(3);
});
});
it('invalidates queries on success', async () => {
const mockResponse = {
success: true,
message: 'Fetched 3 emails',
processed: 3,
errors: 0,
};
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useFetchEmailsNow(), { wrapper });
await act(async () => {
await result.current.mutateAsync(4);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['tickets'] });
});
it('handles API errors during fetch', async () => {
vi.mocked(ticketEmailAddressesApi.fetchEmailsNow).mockRejectedValue(
new Error('Connection timeout')
);
const { result } = renderHook(() => useFetchEmailsNow(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(5);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
});
describe('useSetAsDefault', () => {
it('sets email address as default successfully', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(ticketEmailAddressesApi.setAsDefault).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useSetAsDefault(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response).toEqual(mockResponse);
});
expect(ticketEmailAddressesApi.setAsDefault).toHaveBeenCalledWith(1);
});
it('invalidates query cache on success', async () => {
const mockResponse = {
success: true,
message: 'Email address set as default',
};
vi.mocked(ticketEmailAddressesApi.setAsDefault).mockResolvedValue(mockResponse);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useSetAsDefault(), { wrapper });
await act(async () => {
await result.current.mutateAsync(2);
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['ticketEmailAddresses'] });
});
it('handles errors when setting default', async () => {
vi.mocked(ticketEmailAddressesApi.setAsDefault).mockRejectedValue(
new Error('Cannot set inactive address as default')
);
const { result } = renderHook(() => useSetAsDefault(), {
wrapper: createWrapper(),
});
let caughtError;
await act(async () => {
try {
await result.current.mutateAsync(3);
} catch (error) {
caughtError = error;
}
});
expect(caughtError).toBeInstanceOf(Error);
});
it('handles setting already default address', async () => {
const mockResponse = {
success: true,
message: 'Email address is already the default',
};
vi.mocked(ticketEmailAddressesApi.setAsDefault).mockResolvedValue(mockResponse);
const { result } = renderHook(() => useSetAsDefault(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync(1);
expect(response.message).toBe('Email address is already the default');
});
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,685 @@
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(),
patch: vi.fn(),
},
}));
import {
useUsers,
useStaffForAssignment,
usePlatformStaffForAssignment,
useUpdateStaffPermissions,
} from '../useUsers';
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('useUsers hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useUsers', () => {
it('fetches all staff members', async () => {
const mockStaff = [
{
id: 1,
email: 'owner@example.com',
name: 'John Owner',
username: 'jowner',
role: 'owner',
is_active: true,
permissions: { can_access_resources: true },
can_invite_staff: true,
},
{
id: 2,
email: 'manager@example.com',
name: 'Jane Manager',
username: 'jmanager',
role: 'manager',
is_active: true,
permissions: { can_access_services: false },
can_invite_staff: false,
},
{
id: 3,
email: 'staff@example.com',
name: 'Bob Staff',
username: 'bstaff',
role: 'staff',
is_active: false,
permissions: {},
can_invite_staff: false,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
expect(result.current.data).toHaveLength(3);
expect(result.current.data).toEqual(mockStaff);
});
it('returns empty array when no staff members exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
expect(result.current.data).toEqual([]);
});
it('handles API errors', async () => {
const errorMessage = 'Failed to fetch staff';
vi.mocked(apiClient.get).mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.error).toBeDefined();
});
it('uses correct query key', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useUsers(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Query key should be ['staff'] for caching and invalidation
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
});
});
describe('useStaffForAssignment', () => {
it('fetches and transforms staff for dropdown use', async () => {
const mockStaff = [
{
id: 1,
email: 'john@example.com',
name: 'John Doe',
role: 'owner',
is_active: true,
permissions: {},
},
{
id: 2,
email: 'jane@example.com',
name: 'Jane Smith',
role: 'manager',
is_active: true,
permissions: {},
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/staff/');
expect(result.current.data).toEqual([
{
id: '1',
name: 'John Doe',
email: 'john@example.com',
role: 'owner',
},
{
id: '2',
name: 'Jane Smith',
email: 'jane@example.com',
role: 'manager',
},
]);
});
it('converts id to string', async () => {
const mockStaff = [
{
id: 123,
email: 'test@example.com',
name: 'Test User',
role: 'staff',
is_active: true,
permissions: {},
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].id).toBe('123');
expect(typeof result.current.data?.[0].id).toBe('string');
});
it('falls back to email when name is not provided', async () => {
const mockStaff = [
{
id: 1,
email: 'noname@example.com',
name: null,
role: 'staff',
is_active: true,
permissions: {},
},
{
id: 2,
email: 'emptyname@example.com',
name: '',
role: 'staff',
is_active: true,
permissions: {},
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].name).toBe('noname@example.com');
expect(result.current.data?.[1].name).toBe('emptyname@example.com');
});
it('includes all roles (owner, manager, staff)', async () => {
const mockStaff = [
{
id: 1,
email: 'owner@example.com',
name: 'Owner User',
role: 'owner',
is_active: true,
permissions: {},
},
{
id: 2,
email: 'manager@example.com',
name: 'Manager User',
role: 'manager',
is_active: true,
permissions: {},
},
{
id: 3,
email: 'staff@example.com',
name: 'Staff User',
role: 'staff',
is_active: true,
permissions: {},
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockStaff });
const { result } = renderHook(() => useStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(3);
expect(result.current.data?.map(u => u.role)).toEqual(['owner', 'manager', 'staff']);
});
it('returns empty array when no staff exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => useStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
});
describe('usePlatformStaffForAssignment', () => {
it('fetches and filters platform staff by role', async () => {
const mockPlatformUsers = [
{
id: 1,
email: 'super@platform.com',
name: 'Super User',
role: 'superuser',
},
{
id: 2,
email: 'manager@platform.com',
name: 'Platform Manager',
role: 'platform_manager',
},
{
id: 3,
email: 'support@platform.com',
name: 'Platform Support',
role: 'platform_support',
},
{
id: 4,
email: 'owner@business.com',
name: 'Business Owner',
role: 'owner',
},
{
id: 5,
email: 'staff@business.com',
name: 'Business Staff',
role: 'staff',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/platform/users/');
// Should only return platform roles
expect(result.current.data).toHaveLength(3);
expect(result.current.data?.map(u => u.role)).toEqual([
'superuser',
'platform_manager',
'platform_support',
]);
});
it('transforms platform users for dropdown use', async () => {
const mockPlatformUsers = [
{
id: 10,
email: 'admin@platform.com',
name: 'Admin User',
role: 'superuser',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([
{
id: '10',
name: 'Admin User',
email: 'admin@platform.com',
role: 'superuser',
},
]);
});
it('filters out non-platform roles', async () => {
const mockPlatformUsers = [
{ id: 1, email: 'super@platform.com', name: 'Super', role: 'superuser' },
{ id: 2, email: 'owner@business.com', name: 'Owner', role: 'owner' },
{ id: 3, email: 'manager@business.com', name: 'Manager', role: 'manager' },
{ id: 4, email: 'staff@business.com', name: 'Staff', role: 'staff' },
{ id: 5, email: 'resource@business.com', name: 'Resource', role: 'resource' },
{ id: 6, email: 'customer@business.com', name: 'Customer', role: 'customer' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
// Only superuser should be included from the mock data
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0].role).toBe('superuser');
});
it('includes all three platform roles', async () => {
const mockPlatformUsers = [
{ id: 1, email: 'super@platform.com', name: 'Super', role: 'superuser' },
{ id: 2, email: 'pm@platform.com', name: 'PM', role: 'platform_manager' },
{ id: 3, email: 'support@platform.com', name: 'Support', role: 'platform_support' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
const roles = result.current.data?.map(u => u.role);
expect(roles).toContain('superuser');
expect(roles).toContain('platform_manager');
expect(roles).toContain('platform_support');
});
it('falls back to email when name is missing', async () => {
const mockPlatformUsers = [
{
id: 1,
email: 'noname@platform.com',
role: 'superuser',
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.[0].name).toBe('noname@platform.com');
});
it('returns empty array when no platform users exist', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('returns empty array when only non-platform users exist', async () => {
const mockPlatformUsers = [
{ id: 1, email: 'owner@business.com', name: 'Owner', role: 'owner' },
{ id: 2, email: 'staff@business.com', name: 'Staff', role: 'staff' },
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockPlatformUsers });
const { result } = renderHook(() => usePlatformStaffForAssignment(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
});
describe('useUpdateStaffPermissions', () => {
it('updates staff permissions', async () => {
const updatedStaff = {
id: 5,
email: 'staff@example.com',
name: 'Staff User',
role: 'staff',
is_active: true,
permissions: {
can_access_resources: true,
can_access_services: false,
},
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedStaff });
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
userId: 5,
permissions: {
can_access_resources: true,
can_access_services: false,
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/5/', {
permissions: {
can_access_resources: true,
can_access_services: false,
},
});
});
it('accepts string userId', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
userId: '42',
permissions: { can_access_resources: true },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/42/', {
permissions: { can_access_resources: true },
});
});
it('accepts number userId', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
userId: 123,
permissions: { can_list_customers: true },
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/123/', {
permissions: { can_list_customers: true },
});
});
it('can update multiple permissions at once', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper: createWrapper(),
});
const permissions = {
can_access_resources: true,
can_access_services: true,
can_list_customers: false,
can_access_scheduled_tasks: false,
};
await act(async () => {
await result.current.mutateAsync({
userId: 1,
permissions,
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/1/', {
permissions,
});
});
it('can set permissions to empty object', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
userId: 1,
permissions: {},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/staff/1/', {
permissions: {},
});
});
it('invalidates staff query on success', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper,
});
await act(async () => {
await result.current.mutateAsync({
userId: 1,
permissions: { can_access_resources: true },
});
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['staff'] });
});
});
it('handles API errors', async () => {
const errorMessage = 'Permission update failed';
vi.mocked(apiClient.patch).mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync({
userId: 1,
permissions: { can_access_resources: true },
});
} catch (error) {
expect(error).toBeDefined();
}
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
it('returns updated data from mutation', async () => {
const updatedStaff = {
id: 10,
email: 'updated@example.com',
name: 'Updated User',
role: 'staff',
is_active: true,
permissions: {
can_access_resources: true,
},
};
vi.mocked(apiClient.patch).mockResolvedValue({ data: updatedStaff });
const { result } = renderHook(() => useUpdateStaffPermissions(), {
wrapper: createWrapper(),
});
let mutationResult;
await act(async () => {
mutationResult = await result.current.mutateAsync({
userId: 10,
permissions: { can_access_resources: true },
});
});
expect(mutationResult).toEqual(updatedStaff);
});
});
});

View File

@@ -9,6 +9,7 @@ import {
getCurrentUser,
masquerade,
stopMasquerade,
forgotPassword,
LoginCredentials,
User,
MasqueradeStackEntry
@@ -255,3 +256,12 @@ export const useStopMasquerade = () => {
},
});
};
/**
* Hook to request password reset
*/
export const useForgotPassword = () => {
return useMutation({
mutationFn: (data: { email: string }) => forgotPassword(data.email),
});
};

View File

@@ -60,6 +60,7 @@ export const useCurrentBusiness = () => {
white_label: false,
custom_oauth: false,
plugins: false,
can_create_plugins: false,
tasks: false,
export_data: false,
video_conferencing: false,

View File

@@ -83,7 +83,8 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
custom_domain: 'Custom Domain',
white_label: 'White Label',
custom_oauth: 'Custom OAuth',
plugins: 'Custom Plugins',
plugins: 'Plugins',
can_create_plugins: 'Custom Plugin Creation',
tasks: 'Scheduled Tasks',
export_data: 'Data Export',
video_conferencing: 'Video Conferencing',
@@ -104,7 +105,8 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
custom_domain: 'Use your own custom domain for your booking site',
white_label: 'Remove SmoothSchedule branding and use your own',
custom_oauth: 'Configure your own OAuth credentials for social login',
plugins: 'Create custom plugins to extend functionality',
plugins: 'Install and use plugins from the marketplace',
can_create_plugins: 'Create custom plugins tailored to your business needs',
tasks: 'Create scheduled tasks to automate plugin execution',
export_data: 'Export your data to CSV or other formats',
video_conferencing: 'Add video conferencing links to appointments',