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

688 lines
21 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 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;
});
});
});