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