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:
769
frontend/src/hooks/__tests__/useApiTokens.test.ts
Normal file
769
frontend/src/hooks/__tests__/useApiTokens.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
1114
frontend/src/hooks/__tests__/useAppointments.test.ts
Normal file
1114
frontend/src/hooks/__tests__/useAppointments.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
637
frontend/src/hooks/__tests__/useAuth.test.ts
Normal file
637
frontend/src/hooks/__tests__/useAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
349
frontend/src/hooks/__tests__/useBusiness.test.ts
Normal file
349
frontend/src/hooks/__tests__/useBusiness.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
729
frontend/src/hooks/__tests__/useBusinessOAuth.test.ts
Normal file
729
frontend/src/hooks/__tests__/useBusinessOAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
921
frontend/src/hooks/__tests__/useBusinessOAuthCredentials.test.ts
Normal file
921
frontend/src/hooks/__tests__/useBusinessOAuthCredentials.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
942
frontend/src/hooks/__tests__/useCommunicationCredits.test.ts
Normal file
942
frontend/src/hooks/__tests__/useCommunicationCredits.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1007
frontend/src/hooks/__tests__/useContracts.test.ts
Normal file
1007
frontend/src/hooks/__tests__/useContracts.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
664
frontend/src/hooks/__tests__/useCustomDomains.test.ts
Normal file
664
frontend/src/hooks/__tests__/useCustomDomains.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
687
frontend/src/hooks/__tests__/useCustomerBilling.test.ts
Normal file
687
frontend/src/hooks/__tests__/useCustomerBilling.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
224
frontend/src/hooks/__tests__/useCustomers.test.ts
Normal file
224
frontend/src/hooks/__tests__/useCustomers.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
958
frontend/src/hooks/__tests__/useDomains.test.ts
Normal file
958
frontend/src/hooks/__tests__/useDomains.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
902
frontend/src/hooks/__tests__/useInvitations.test.ts
Normal file
902
frontend/src/hooks/__tests__/useInvitations.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
142
frontend/src/hooks/__tests__/useNotifications.test.ts
Normal file
142
frontend/src/hooks/__tests__/useNotifications.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
549
frontend/src/hooks/__tests__/useOAuth.test.ts
Normal file
549
frontend/src/hooks/__tests__/useOAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
584
frontend/src/hooks/__tests__/usePayments.test.ts
Normal file
584
frontend/src/hooks/__tests__/usePayments.test.ts
Normal 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() });
|
||||
});
|
||||
});
|
||||
});
|
||||
864
frontend/src/hooks/__tests__/usePlanFeatures.test.ts
Normal file
864
frontend/src/hooks/__tests__/usePlanFeatures.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
1196
frontend/src/hooks/__tests__/usePlatform.test.ts
Normal file
1196
frontend/src/hooks/__tests__/usePlatform.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1186
frontend/src/hooks/__tests__/usePlatformEmailAddresses.test.ts
Normal file
1186
frontend/src/hooks/__tests__/usePlatformEmailAddresses.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1561
frontend/src/hooks/__tests__/usePlatformOAuth.test.ts
Normal file
1561
frontend/src/hooks/__tests__/usePlatformOAuth.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1024
frontend/src/hooks/__tests__/usePlatformSettings.test.ts
Normal file
1024
frontend/src/hooks/__tests__/usePlatformSettings.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
461
frontend/src/hooks/__tests__/useProfile.test.ts
Normal file
461
frontend/src/hooks/__tests__/useProfile.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
561
frontend/src/hooks/__tests__/useResourceLocation.test.ts
Normal file
561
frontend/src/hooks/__tests__/useResourceLocation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
660
frontend/src/hooks/__tests__/useResourceTypes.test.ts
Normal file
660
frontend/src/hooks/__tests__/useResourceTypes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
242
frontend/src/hooks/__tests__/useResources.test.ts
Normal file
242
frontend/src/hooks/__tests__/useResources.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
579
frontend/src/hooks/__tests__/useSandbox.test.ts
Normal file
579
frontend/src/hooks/__tests__/useSandbox.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
238
frontend/src/hooks/__tests__/useServices.test.ts
Normal file
238
frontend/src/hooks/__tests__/useServices.test.ts
Normal 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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
522
frontend/src/hooks/__tests__/useStaff.test.ts
Normal file
522
frontend/src/hooks/__tests__/useStaff.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
842
frontend/src/hooks/__tests__/useTicketEmailAddresses.test.ts
Normal file
842
frontend/src/hooks/__tests__/useTicketEmailAddresses.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1030
frontend/src/hooks/__tests__/useTicketEmailSettings.test.ts
Normal file
1030
frontend/src/hooks/__tests__/useTicketEmailSettings.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1063
frontend/src/hooks/__tests__/useTickets.test.ts
Normal file
1063
frontend/src/hooks/__tests__/useTickets.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1047
frontend/src/hooks/__tests__/useTimeBlocks.test.tsx
Normal file
1047
frontend/src/hooks/__tests__/useTimeBlocks.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1052
frontend/src/hooks/__tests__/useTransactionAnalytics.test.ts
Normal file
1052
frontend/src/hooks/__tests__/useTransactionAnalytics.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
685
frontend/src/hooks/__tests__/useUsers.test.ts
Normal file
685
frontend/src/hooks/__tests__/useUsers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user