Files
smoothschedule/frontend/src/hooks/__tests__/useApiTokens.test.ts
poduck 8dc2248f1f 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>
2025-12-08 02:36:46 -05:00

770 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock 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();
});
});
});