Files
smoothschedule/frontend/src/hooks/__tests__/usePayments.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

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