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