POS System: - Full POS interface with product grid, cart panel, and payment flow - Product and category management with barcode scanning support - Cash drawer operations and shift management - Order history and receipt generation - Thermal printer integration (ESC/POS protocol) - Gift card support with purchase and redemption - Inventory tracking with low stock alerts - Customer selection and walk-in support Tax Rate Integration: - ZIP-to-state mapping for automatic state detection - SST boundary data import for 24 member states - Static rates for uniform-rate states (IN, MA, CT, etc.) - Statewide jurisdiction fallback for simple lookups - Tax rate suggestion in location editor with auto-apply - Multiple data sources: SST, CDTFA, TX Comptroller, Avalara UI Improvements: - POS renders full-screen outside BusinessLayout - Clear cart button prominently in cart header - Tax rate limited to 2 decimal places - Location tax rate field with suggestion UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
540 lines
14 KiB
TypeScript
540 lines
14 KiB
TypeScript
/**
|
|
* Tests for GiftCardPaymentPanel Component
|
|
*
|
|
* Features:
|
|
* - Gift card code input (manual entry or scan)
|
|
* - Look up button to check balance
|
|
* - Shows card balance when found
|
|
* - Amount to redeem input
|
|
* - Apply button to add gift card payment
|
|
* - Error handling for invalid/expired cards
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import React from 'react';
|
|
import GiftCardPaymentPanel from '../GiftCardPaymentPanel';
|
|
import * as useGiftCardsHooks from '../../hooks/useGiftCards';
|
|
import type { GiftCard } from '../../types';
|
|
|
|
// Mock the useGiftCards hooks
|
|
vi.mock('../../hooks/useGiftCards');
|
|
|
|
describe('GiftCardPaymentPanel', () => {
|
|
let queryClient: QueryClient;
|
|
const mockOnApply = vi.fn();
|
|
const mockOnCancel = vi.fn();
|
|
|
|
const createWrapper = () => {
|
|
queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
// eslint-disable-next-line react/display-name
|
|
return ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should render gift card input form', () => {
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: false,
|
|
data: undefined,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByPlaceholderText(/enter gift card code/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /lookup/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should allow user to enter gift card code', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: false,
|
|
data: undefined,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/enter gift card code/i);
|
|
await user.type(input, 'GC-ABC123');
|
|
|
|
expect(input).toHaveValue('GC-ABC123');
|
|
});
|
|
|
|
it('should lookup gift card when lookup button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
const mockMutate = vi.fn();
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: mockMutate,
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: false,
|
|
data: undefined,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/enter gift card code/i);
|
|
const lookupButton = screen.getByRole('button', { name: /lookup/i });
|
|
|
|
await user.type(input, 'GC-ABC123');
|
|
await user.click(lookupButton);
|
|
|
|
expect(mockMutate).toHaveBeenCalledWith('GC-ABC123');
|
|
});
|
|
|
|
it('should display gift card balance when lookup succeeds', () => {
|
|
const mockGiftCard: GiftCard = {
|
|
id: 1,
|
|
code: 'GC-ABC123',
|
|
initial_balance_cents: 10000,
|
|
current_balance_cents: 7500,
|
|
status: 'active',
|
|
purchased_by: null,
|
|
recipient_email: '',
|
|
recipient_name: '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
};
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isError: false,
|
|
data: mockGiftCard,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByText(/GC-ABC123/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/\$75\.00/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show loading state during lookup', () => {
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: true,
|
|
isSuccess: false,
|
|
isError: false,
|
|
data: undefined,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: /lookup/i })).toBeDisabled();
|
|
});
|
|
|
|
it('should show error for invalid gift card', () => {
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: true,
|
|
data: undefined,
|
|
error: { message: 'Gift card not found' } as any,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByText(/gift card not found/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show error for expired gift card', () => {
|
|
const expiredGiftCard: GiftCard = {
|
|
id: 1,
|
|
code: 'GC-EXPIRED',
|
|
initial_balance_cents: 5000,
|
|
current_balance_cents: 5000,
|
|
status: 'expired',
|
|
purchased_by: null,
|
|
recipient_email: '',
|
|
recipient_name: '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
expires_at: '2024-06-01T00:00:00Z',
|
|
};
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isError: false,
|
|
data: expiredGiftCard,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByText(/expired/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should show error for depleted gift card', () => {
|
|
const depletedGiftCard: GiftCard = {
|
|
id: 1,
|
|
code: 'GC-DEPLETED',
|
|
initial_balance_cents: 5000,
|
|
current_balance_cents: 0,
|
|
status: 'depleted',
|
|
purchased_by: null,
|
|
recipient_email: '',
|
|
recipient_name: '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
};
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isError: false,
|
|
data: depletedGiftCard,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByText(/no balance remaining/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should default redemption amount to remaining balance or amount due, whichever is less', () => {
|
|
const mockGiftCard: GiftCard = {
|
|
id: 1,
|
|
code: 'GC-ABC123',
|
|
initial_balance_cents: 10000,
|
|
current_balance_cents: 7500,
|
|
status: 'active',
|
|
purchased_by: null,
|
|
recipient_email: '',
|
|
recipient_name: '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
};
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isError: false,
|
|
data: mockGiftCard,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// FormCurrencyInput displays value in dollars as formatted text
|
|
// With amountDue=5000 cents ($50), this should be the default
|
|
expect(screen.getByDisplayValue('$50.00')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should allow user to change redemption amount', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
const mockGiftCard: GiftCard = {
|
|
id: 1,
|
|
code: 'GC-ABC123',
|
|
initial_balance_cents: 10000,
|
|
current_balance_cents: 10000,
|
|
status: 'active',
|
|
purchased_by: null,
|
|
recipient_email: '',
|
|
recipient_name: '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
};
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isError: false,
|
|
data: mockGiftCard,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Find the currency input by its placeholder or current value
|
|
const amountInput = screen.getByDisplayValue('$50.00');
|
|
await user.clear(amountInput);
|
|
await user.type(amountInput, '25');
|
|
|
|
// After typing "25", the display should show "$0.25" (25 cents)
|
|
expect(screen.getByDisplayValue('$0.25')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should call onApply with correct payment info when Apply is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
const mockGiftCard: GiftCard = {
|
|
id: 1,
|
|
code: 'GC-ABC123',
|
|
initial_balance_cents: 10000,
|
|
current_balance_cents: 7500,
|
|
status: 'active',
|
|
purchased_by: null,
|
|
recipient_email: '',
|
|
recipient_name: '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
};
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isError: false,
|
|
data: mockGiftCard,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
const applyButton = screen.getByRole('button', { name: /apply/i });
|
|
await user.click(applyButton);
|
|
|
|
expect(mockOnApply).toHaveBeenCalledWith({
|
|
gift_card_code: 'GC-ABC123',
|
|
amount_cents: 5000,
|
|
gift_card: mockGiftCard,
|
|
});
|
|
});
|
|
|
|
it('should not allow applying more than gift card balance', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
const mockGiftCard: GiftCard = {
|
|
id: 1,
|
|
code: 'GC-ABC123',
|
|
initial_balance_cents: 5000,
|
|
current_balance_cents: 2500,
|
|
status: 'active',
|
|
purchased_by: null,
|
|
recipient_email: '',
|
|
recipient_name: '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
expires_at: null,
|
|
};
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: true,
|
|
isError: false,
|
|
data: mockGiftCard,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Default amount should be $25 (2500 cents) since that's the card balance
|
|
const amountInput = screen.getByDisplayValue('$25.00');
|
|
await user.clear(amountInput);
|
|
await user.type(amountInput, '5000'); // Try to redeem $50.00 (5000 cents) when balance is $25
|
|
|
|
const applyButton = screen.getByRole('button', { name: /apply/i });
|
|
await user.click(applyButton);
|
|
|
|
// Should show error, not call onApply
|
|
expect(screen.getByText(/exceeds gift card balance/i)).toBeInTheDocument();
|
|
expect(mockOnApply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call onCancel when Cancel button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: false,
|
|
data: undefined,
|
|
error: null,
|
|
reset: vi.fn(),
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
|
await user.click(cancelButton);
|
|
|
|
expect(mockOnCancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reset form when a new lookup is performed', async () => {
|
|
const user = userEvent.setup();
|
|
const mockMutate = vi.fn();
|
|
const mockReset = vi.fn();
|
|
|
|
vi.mocked(useGiftCardsHooks.useLookupGiftCard).mockReturnValue({
|
|
mutate: mockMutate,
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
isSuccess: false,
|
|
isError: false,
|
|
data: undefined,
|
|
error: null,
|
|
reset: mockReset,
|
|
} as any);
|
|
|
|
render(
|
|
<GiftCardPaymentPanel
|
|
amountDueCents={5000}
|
|
onApply={mockOnApply}
|
|
onCancel={mockOnCancel}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
const input = screen.getByPlaceholderText(/enter gift card code/i);
|
|
const lookupButton = screen.getByRole('button', { name: /lookup/i });
|
|
|
|
await user.type(input, 'GC-ABC123');
|
|
await user.click(lookupButton);
|
|
|
|
// Clear and lookup another card
|
|
await user.clear(input);
|
|
await user.type(input, 'GC-XYZ789');
|
|
|
|
expect(mockReset).toHaveBeenCalled();
|
|
});
|
|
});
|