- 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>
1053 lines
32 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|