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

1053 lines
32 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', () => ({
getTransactions: vi.fn(),
getTransaction: vi.fn(),
getTransactionSummary: vi.fn(),
getStripeCharges: vi.fn(),
getStripePayouts: vi.fn(),
getStripeBalance: vi.fn(),
exportTransactions: vi.fn(),
getTransactionDetail: vi.fn(),
refundTransaction: vi.fn(),
}));
import {
useTransactions,
useTransaction,
useTransactionSummary,
useStripeCharges,
useStripePayouts,
useStripeBalance,
useExportTransactions,
useInvalidateTransactions,
useTransactionDetail,
useRefundTransaction,
} from '../useTransactionAnalytics';
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('useTransactionAnalytics hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useTransactions', () => {
it('fetches paginated transaction list without filters', async () => {
const mockTransactions = {
results: [
{
id: 1,
business: 1,
business_name: 'Test Business',
stripe_payment_intent_id: 'pi_123',
stripe_charge_id: 'ch_123',
transaction_type: 'payment' as const,
status: 'succeeded' as const,
amount: 5000,
amount_display: '$50.00',
application_fee_amount: 150,
fee_display: '$1.50',
net_amount: 4850,
currency: 'usd',
customer_email: 'customer@example.com',
customer_name: 'John Doe',
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
},
],
count: 1,
page: 1,
page_size: 20,
total_pages: 1,
};
vi.mocked(paymentsApi.getTransactions).mockResolvedValue({ data: mockTransactions } as any);
const { result } = renderHook(() => useTransactions(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getTransactions).toHaveBeenCalledWith(undefined);
expect(result.current.data).toEqual(mockTransactions);
});
it('fetches transactions with filters', async () => {
const mockTransactions = {
results: [],
count: 0,
page: 1,
page_size: 10,
total_pages: 0,
};
const filters = {
start_date: '2025-12-01',
end_date: '2025-12-07',
status: 'succeeded' as const,
transaction_type: 'payment' as const,
page: 1,
page_size: 10,
};
vi.mocked(paymentsApi.getTransactions).mockResolvedValue({ data: mockTransactions } as any);
const { result } = renderHook(() => useTransactions(filters), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getTransactions).toHaveBeenCalledWith(filters);
expect(result.current.data).toEqual(mockTransactions);
});
it('uses 30 second staleTime', async () => {
vi.mocked(paymentsApi.getTransactions).mockResolvedValue({ data: { results: [] } } 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(() => useTransactions(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['transactions', undefined]);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['transactions', undefined]);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
it('creates different query keys for different filters', async () => {
vi.mocked(paymentsApi.getTransactions).mockResolvedValue({ data: { results: [] } } 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);
const filters1 = { start_date: '2025-12-01' };
const filters2 = { start_date: '2025-12-07' };
const { rerender } = renderHook(
({ filters }) => useTransactions(filters),
{
wrapper,
initialProps: { filters: filters1 },
}
);
await waitFor(() => {
expect(queryClient.getQueryState(['transactions', filters1])).toBeDefined();
});
rerender({ filters: filters2 });
await waitFor(() => {
expect(queryClient.getQueryState(['transactions', filters2])).toBeDefined();
});
expect(paymentsApi.getTransactions).toHaveBeenCalledWith(filters1);
expect(paymentsApi.getTransactions).toHaveBeenCalledWith(filters2);
});
});
describe('useTransaction', () => {
it('fetches a single transaction by ID', async () => {
const mockTransaction = {
id: 1,
business: 1,
business_name: 'Test Business',
stripe_payment_intent_id: 'pi_123',
stripe_charge_id: 'ch_123',
transaction_type: 'payment' as const,
status: 'succeeded' as const,
amount: 5000,
amount_display: '$50.00',
application_fee_amount: 150,
fee_display: '$1.50',
net_amount: 4850,
currency: 'usd',
customer_email: 'customer@example.com',
customer_name: 'John Doe',
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
};
vi.mocked(paymentsApi.getTransaction).mockResolvedValue({ data: mockTransaction } as any);
const { result } = renderHook(() => useTransaction(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getTransaction).toHaveBeenCalledWith(1);
expect(result.current.data).toEqual(mockTransaction);
});
it('is disabled when ID is falsy', async () => {
vi.mocked(paymentsApi.getTransaction).mockResolvedValue({ data: {} } as any);
const { result } = renderHook(() => useTransaction(0), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isPending).toBe(true);
});
expect(paymentsApi.getTransaction).not.toHaveBeenCalled();
});
});
describe('useTransactionSummary', () => {
it('fetches transaction summary without filters', async () => {
const mockSummary = {
total_transactions: 100,
total_volume: 50000,
total_volume_display: '$500.00',
total_fees: 1500,
total_fees_display: '$15.00',
net_revenue: 48500,
net_revenue_display: '$485.00',
successful_transactions: 95,
failed_transactions: 3,
refunded_transactions: 2,
average_transaction: 500,
average_transaction_display: '$5.00',
};
vi.mocked(paymentsApi.getTransactionSummary).mockResolvedValue({ data: mockSummary } as any);
const { result } = renderHook(() => useTransactionSummary(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getTransactionSummary).toHaveBeenCalledWith(undefined);
expect(result.current.data).toEqual(mockSummary);
});
it('fetches transaction summary with date filters', async () => {
const mockSummary = {
total_transactions: 50,
total_volume: 25000,
total_volume_display: '$250.00',
total_fees: 750,
total_fees_display: '$7.50',
net_revenue: 24250,
net_revenue_display: '$242.50',
successful_transactions: 48,
failed_transactions: 1,
refunded_transactions: 1,
average_transaction: 500,
average_transaction_display: '$5.00',
};
const filters = {
start_date: '2025-12-01',
end_date: '2025-12-07',
};
vi.mocked(paymentsApi.getTransactionSummary).mockResolvedValue({ data: mockSummary } as any);
const { result } = renderHook(() => useTransactionSummary(filters), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getTransactionSummary).toHaveBeenCalledWith(filters);
expect(result.current.data).toEqual(mockSummary);
});
it('uses 1 minute staleTime', async () => {
vi.mocked(paymentsApi.getTransactionSummary).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(() => useTransactionSummary(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['transactionSummary', undefined]);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['transactionSummary', undefined]);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useStripeCharges', () => {
it('fetches Stripe charges with default limit', async () => {
const mockCharges = {
charges: [
{
id: 'ch_123',
amount: 5000,
amount_display: '$50.00',
amount_refunded: 0,
currency: 'usd',
status: 'succeeded',
paid: true,
refunded: false,
description: 'Test charge',
receipt_email: 'customer@example.com',
receipt_url: 'https://stripe.com/receipt',
created: 1733572800,
payment_method_details: null,
billing_details: null,
},
],
has_more: false,
};
vi.mocked(paymentsApi.getStripeCharges).mockResolvedValue({ data: mockCharges } as any);
const { result } = renderHook(() => useStripeCharges(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getStripeCharges).toHaveBeenCalledWith(20);
expect(result.current.data).toEqual(mockCharges);
});
it('fetches Stripe charges with custom limit', async () => {
const mockCharges = {
charges: [],
has_more: false,
};
vi.mocked(paymentsApi.getStripeCharges).mockResolvedValue({ data: mockCharges } as any);
const { result } = renderHook(() => useStripeCharges(50), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getStripeCharges).toHaveBeenCalledWith(50);
});
it('uses 30 second staleTime', async () => {
vi.mocked(paymentsApi.getStripeCharges).mockResolvedValue({ data: { charges: [] } } 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(() => useStripeCharges(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['stripeCharges', 20]);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['stripeCharges', 20]);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useStripePayouts', () => {
it('fetches Stripe payouts with default limit', async () => {
const mockPayouts = {
payouts: [
{
id: 'po_123',
amount: 10000,
amount_display: '$100.00',
currency: 'usd',
status: 'paid',
arrival_date: 1733659200,
created: 1733572800,
description: 'STRIPE PAYOUT',
destination: 'ba_123',
failure_message: null,
method: 'standard',
type: 'bank_account',
},
],
has_more: false,
};
vi.mocked(paymentsApi.getStripePayouts).mockResolvedValue({ data: mockPayouts } as any);
const { result } = renderHook(() => useStripePayouts(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getStripePayouts).toHaveBeenCalledWith(20);
expect(result.current.data).toEqual(mockPayouts);
});
it('fetches Stripe payouts with custom limit', async () => {
const mockPayouts = {
payouts: [],
has_more: true,
};
vi.mocked(paymentsApi.getStripePayouts).mockResolvedValue({ data: mockPayouts } as any);
const { result } = renderHook(() => useStripePayouts(10), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getStripePayouts).toHaveBeenCalledWith(10);
});
it('uses 30 second staleTime', async () => {
vi.mocked(paymentsApi.getStripePayouts).mockResolvedValue({ data: { payouts: [] } } 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(() => useStripePayouts(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['stripePayouts', 20]);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['stripePayouts', 20]);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useStripeBalance', () => {
it('fetches Stripe balance', async () => {
const mockBalance = {
available: [
{
amount: 10000,
currency: 'usd',
amount_display: '$100.00',
},
],
pending: [
{
amount: 5000,
currency: 'usd',
amount_display: '$50.00',
},
],
available_total: 10000,
pending_total: 5000,
};
vi.mocked(paymentsApi.getStripeBalance).mockResolvedValue({ data: mockBalance } as any);
const { result } = renderHook(() => useStripeBalance(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getStripeBalance).toHaveBeenCalledTimes(1);
expect(result.current.data).toEqual(mockBalance);
});
it('uses 1 minute staleTime and 5 minute refetchInterval', async () => {
vi.mocked(paymentsApi.getStripeBalance).mockResolvedValue({
data: {
available: [],
pending: [],
available_total: 0,
pending_total: 0,
},
} 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(() => useStripeBalance(), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['stripeBalance']);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['stripeBalance']);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useExportTransactions', () => {
beforeEach(() => {
// Mock URL and Blob APIs
global.URL.createObjectURL = vi.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = vi.fn();
// Spy on document methods
vi.spyOn(document, 'createElement');
vi.spyOn(document.body, 'appendChild');
vi.spyOn(document.body, 'removeChild');
});
it('exports transactions as CSV', async () => {
const mockBlob = { type: 'text/csv', size: 100 };
const mockResponse = {
data: mockBlob,
headers: { 'content-type': 'text/csv' },
};
vi.mocked(paymentsApi.exportTransactions).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useExportTransactions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
format: 'csv',
start_date: '2025-12-01',
end_date: '2025-12-07',
});
});
expect(paymentsApi.exportTransactions).toHaveBeenCalledWith({
format: 'csv',
start_date: '2025-12-01',
end_date: '2025-12-07',
});
});
it('exports transactions as XLSX', async () => {
const mockBlob = {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 100,
};
const mockResponse = {
data: mockBlob,
headers: {
'content-type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
};
vi.mocked(paymentsApi.exportTransactions).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useExportTransactions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
format: 'xlsx',
});
});
expect(paymentsApi.exportTransactions).toHaveBeenCalledWith({
format: 'xlsx',
});
});
it('exports transactions as PDF', async () => {
const mockBlob = { type: 'application/pdf', size: 100 };
const mockResponse = {
data: mockBlob,
headers: { 'content-type': 'application/pdf' },
};
vi.mocked(paymentsApi.exportTransactions).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useExportTransactions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
format: 'pdf',
include_details: true,
});
});
expect(paymentsApi.exportTransactions).toHaveBeenCalledWith({
format: 'pdf',
include_details: true,
});
});
it('exports transactions as QuickBooks IIF', async () => {
const mockBlob = { type: 'text/plain', size: 100 };
const mockResponse = {
data: mockBlob,
headers: { 'content-type': 'text/plain' },
};
vi.mocked(paymentsApi.exportTransactions).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useExportTransactions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
format: 'quickbooks',
});
});
expect(paymentsApi.exportTransactions).toHaveBeenCalledWith({
format: 'quickbooks',
});
});
it('triggers file download on success', async () => {
const mockBlob = { type: 'text/csv', size: 100 };
const mockResponse = {
data: mockBlob,
headers: { 'content-type': 'text/csv' },
};
vi.mocked(paymentsApi.exportTransactions).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useExportTransactions(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ format: 'csv' });
});
expect(global.URL.createObjectURL).toHaveBeenCalled();
expect(document.createElement).toHaveBeenCalledWith('a');
expect(document.body.appendChild).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('uses correct file extension for each format', async () => {
const mockBlob = { type: 'text/plain', size: 100 };
const mockResponse = {
data: mockBlob,
headers: { 'content-type': 'text/plain' },
};
vi.mocked(paymentsApi.exportTransactions).mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useExportTransactions(), {
wrapper: createWrapper(),
});
// CSV
await act(async () => {
await result.current.mutateAsync({ format: 'csv' });
});
const csvLink = (document.createElement as any).mock.results.find((r: any) => r.value?.download?.endsWith('.csv'))?.value;
expect(csvLink?.download).toBe('transactions.csv');
// XLSX
await act(async () => {
await result.current.mutateAsync({ format: 'xlsx' });
});
const xlsxLink = (document.createElement as any).mock.results.find((r: any) => r.value?.download?.endsWith('.xlsx'))?.value;
expect(xlsxLink?.download).toBe('transactions.xlsx');
// PDF
await act(async () => {
await result.current.mutateAsync({ format: 'pdf' });
});
const pdfLink = (document.createElement as any).mock.results.find((r: any) => r.value?.download?.endsWith('.pdf'))?.value;
expect(pdfLink?.download).toBe('transactions.pdf');
// QuickBooks
await act(async () => {
await result.current.mutateAsync({ format: 'quickbooks' });
});
const iifLink = (document.createElement as any).mock.results.find((r: any) => r.value?.download?.endsWith('.iif'))?.value;
expect(iifLink?.download).toBe('transactions.iif');
});
});
describe('useInvalidateTransactions', () => {
it('invalidates all transaction-related queries', async () => {
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(() => useInvalidateTransactions(), { wrapper });
act(() => {
result.current();
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['transactions'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['transactionSummary'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['stripeCharges'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['stripePayouts'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['stripeBalance'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['transactionDetail'] });
expect(invalidateQueriesSpy).toHaveBeenCalledTimes(6);
});
});
describe('useTransactionDetail', () => {
it('fetches detailed transaction information', async () => {
const mockDetailedTransaction = {
id: 1,
business: 1,
business_name: 'Test Business',
stripe_payment_intent_id: 'pi_123',
stripe_charge_id: 'ch_123',
transaction_type: 'payment' as const,
status: 'partially_refunded' as const,
amount: 5000,
amount_display: '$50.00',
application_fee_amount: 150,
fee_display: '$1.50',
net_amount: 4850,
currency: 'usd',
customer_email: 'customer@example.com',
customer_name: 'John Doe',
created_at: '2025-12-07T10:00:00Z',
updated_at: '2025-12-07T10:00:00Z',
refunds: [
{
id: 're_123',
amount: 1000,
amount_display: '$10.00',
status: 'succeeded',
reason: 'requested_by_customer',
created: 1733572800,
},
],
refundable_amount: 4000,
total_refunded: 1000,
can_refund: true,
payment_method_info: {
type: 'card',
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2026,
funding: 'credit',
},
description: 'Payment for service',
};
vi.mocked(paymentsApi.getTransactionDetail).mockResolvedValue({
data: mockDetailedTransaction,
} as any);
const { result } = renderHook(() => useTransactionDetail(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(paymentsApi.getTransactionDetail).toHaveBeenCalledWith(1);
expect(result.current.data).toEqual(mockDetailedTransaction);
});
it('returns null when ID is null', async () => {
vi.mocked(paymentsApi.getTransactionDetail).mockResolvedValue({ data: null } as any);
const { result } = renderHook(() => useTransactionDetail(null), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isPending).toBe(true);
});
expect(paymentsApi.getTransactionDetail).not.toHaveBeenCalled();
});
it('is disabled when ID is null', async () => {
vi.mocked(paymentsApi.getTransactionDetail).mockResolvedValue({ data: {} } as any);
const { result } = renderHook(() => useTransactionDetail(null), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isPending).toBe(true);
});
expect(paymentsApi.getTransactionDetail).not.toHaveBeenCalled();
});
it('uses 10 second staleTime for live data', async () => {
vi.mocked(paymentsApi.getTransactionDetail).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(() => useTransactionDetail(1), { wrapper });
await waitFor(() => {
const queryState = queryClient.getQueryState(['transactionDetail', 1]);
expect(queryState).toBeDefined();
});
const queryState = queryClient.getQueryState(['transactionDetail', 1]);
expect(queryState?.dataUpdatedAt).toBeDefined();
});
});
describe('useRefundTransaction', () => {
it('issues a full refund successfully', async () => {
const mockRefundResponse = {
success: true,
refund_id: 're_123',
amount: 5000,
amount_display: '$50.00',
status: 'succeeded',
reason: null,
transaction_status: 'refunded',
};
vi.mocked(paymentsApi.refundTransaction).mockResolvedValue({
data: mockRefundResponse,
} as any);
const { result } = renderHook(() => useRefundTransaction(), {
wrapper: createWrapper(),
});
await act(async () => {
const response = await result.current.mutateAsync({
transactionId: 1,
});
expect(response).toEqual(mockRefundResponse);
});
expect(paymentsApi.refundTransaction).toHaveBeenCalledWith(1, undefined);
});
it('issues a partial refund successfully', async () => {
const mockRefundResponse = {
success: true,
refund_id: 're_456',
amount: 1000,
amount_display: '$10.00',
status: 'succeeded',
reason: 'requested_by_customer',
transaction_status: 'partially_refunded',
};
vi.mocked(paymentsApi.refundTransaction).mockResolvedValue({
data: mockRefundResponse,
} as any);
const { result } = renderHook(() => useRefundTransaction(), {
wrapper: createWrapper(),
});
const refundRequest = {
amount: 1000,
reason: 'requested_by_customer' as const,
};
await act(async () => {
const response = await result.current.mutateAsync({
transactionId: 1,
request: refundRequest,
});
expect(response).toEqual(mockRefundResponse);
});
expect(paymentsApi.refundTransaction).toHaveBeenCalledWith(1, refundRequest);
});
it('issues refund with metadata', async () => {
const mockRefundResponse = {
success: true,
refund_id: 're_789',
amount: 2000,
amount_display: '$20.00',
status: 'succeeded',
reason: 'duplicate',
transaction_status: 'partially_refunded',
};
vi.mocked(paymentsApi.refundTransaction).mockResolvedValue({
data: mockRefundResponse,
} as any);
const { result } = renderHook(() => useRefundTransaction(), {
wrapper: createWrapper(),
});
const refundRequest = {
amount: 2000,
reason: 'duplicate' as const,
metadata: {
refund_reason: 'Duplicate charge',
refunded_by: 'admin@example.com',
},
};
await act(async () => {
await result.current.mutateAsync({
transactionId: 1,
request: refundRequest,
});
});
expect(paymentsApi.refundTransaction).toHaveBeenCalledWith(1, refundRequest);
});
it('invalidates relevant queries on success', async () => {
vi.mocked(paymentsApi.refundTransaction).mockResolvedValue({
data: { success: true, refund_id: 're_123' },
} 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(() => useRefundTransaction(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
transactionId: 1,
});
});
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['transactions'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['transactionSummary'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['transactionDetail', 1] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['stripeCharges'] });
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['stripeBalance'] });
});
it('handles refund errors', async () => {
const mockError = new Error('Refund failed: Insufficient funds');
vi.mocked(paymentsApi.refundTransaction).mockRejectedValue(mockError);
const { result } = renderHook(() => useRefundTransaction(), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync({
transactionId: 1,
});
// If no error thrown, fail the test
throw new Error('Expected mutation to fail');
} catch (error) {
expect(error).toEqual(mockError);
}
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
});