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>
390 lines
11 KiB
TypeScript
390 lines
11 KiB
TypeScript
/**
|
|
* Tests for CloseShiftModal component
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import CloseShiftModal from '../CloseShiftModal';
|
|
import { useCloseShift } from '../../hooks/useCashDrawer';
|
|
import type { CashShift } from '../../types';
|
|
|
|
// Mock hooks
|
|
vi.mock('../../hooks/useCashDrawer');
|
|
|
|
const mockUseCloseShift = useCloseShift as any;
|
|
|
|
const mockShift: CashShift = {
|
|
id: 1,
|
|
location: 1,
|
|
status: 'open',
|
|
opening_balance_cents: 10000,
|
|
expected_balance_cents: 15000,
|
|
opened_at: '2024-12-26T09:00:00Z',
|
|
opened_by: 1,
|
|
closed_by: null,
|
|
actual_balance_cents: null,
|
|
variance_cents: null,
|
|
cash_breakdown: {},
|
|
closing_notes: '',
|
|
opening_notes: '',
|
|
closed_at: null,
|
|
};
|
|
|
|
const createWrapper = () => {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
return ({ children }: { children: React.ReactNode }) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
describe('CloseShiftModal', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should render when open', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByText(/close cash drawer/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/count cash/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display expected balance prominently', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByText(/expected balance/i)).toBeInTheDocument();
|
|
// Check for the expected balance in the blue box
|
|
const expectedBalanceElements = screen.getAllByText(/\$150\.00/);
|
|
expect(expectedBalanceElements.length).toBeGreaterThan(0);
|
|
expect(expectedBalanceElements[0]).toHaveClass('text-blue-900');
|
|
});
|
|
|
|
it('should have denomination counter inputs', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Bills
|
|
expect(screen.getByLabelText(/\$100 bills/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/\$50 bills/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/\$20 bills/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/\$10 bills/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/\$5 bills/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/\$1 bills/i)).toBeInTheDocument();
|
|
|
|
// Coins
|
|
expect(screen.getByLabelText(/quarters/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/dimes/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/nickels/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/pennies/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should calculate total from denominations', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Enter: 1x $100, 2x $20, 1x $5
|
|
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
|
fireEvent.change(screen.getByLabelText(/\$20 bills/i), { target: { value: '2' } });
|
|
fireEvent.change(screen.getByLabelText(/\$5 bills/i), { target: { value: '1' } });
|
|
|
|
// Total should be $145.00 - look for it in the "Actual Balance" section
|
|
expect(screen.getByText('Actual Balance')).toBeInTheDocument();
|
|
const actualBalanceElements = screen.getAllByText(/\$145\.00/);
|
|
expect(actualBalanceElements.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should show variance in green when actual matches expected', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Enter exactly $150.00 (expected balance)
|
|
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
|
fireEvent.change(screen.getByLabelText(/\$50 bills/i), { target: { value: '1' } });
|
|
|
|
// Find variance section - get parent container with background
|
|
const varianceLabel = screen.getByText('Variance');
|
|
const parentDiv = varianceLabel.parentElement;
|
|
expect(parentDiv).toHaveClass('bg-green-50');
|
|
|
|
// Variance amount should be green
|
|
const varianceAmounts = screen.getAllByText(/\$0\.00/);
|
|
const varianceAmount = varianceAmounts.find(el => el.classList.contains('text-green-600'));
|
|
expect(varianceAmount).toBeDefined();
|
|
});
|
|
|
|
it('should show variance in red when actual is short', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Enter $100.00 (short by $50)
|
|
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
|
|
|
// Find variance section - should be red
|
|
const varianceLabel = screen.getByText('Variance');
|
|
const parentDiv = varianceLabel.parentElement;
|
|
expect(parentDiv).toHaveClass('bg-red-50');
|
|
|
|
// Variance amount should be red
|
|
const varianceText = screen.getByText(/-\$50\.00/);
|
|
expect(varianceText).toHaveClass('text-red-600');
|
|
});
|
|
|
|
it('should show variance in green when actual is over', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Enter $200.00 (over by $50)
|
|
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '2' } });
|
|
|
|
// Find variance section - should be green
|
|
const varianceLabel = screen.getByText('Variance');
|
|
const parentDiv = varianceLabel.parentElement;
|
|
expect(parentDiv).toHaveClass('bg-green-50');
|
|
|
|
// Variance amount should be green and positive
|
|
const varianceText = screen.getByText(/\+\$50\.00/);
|
|
expect(varianceText).toHaveClass('text-green-600');
|
|
});
|
|
|
|
it('should allow adding closing notes', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
const notesInput = screen.getByPlaceholderText(/notes about the shift/i);
|
|
fireEvent.change(notesInput, { target: { value: 'Short due to refund' } });
|
|
|
|
expect(notesInput).toHaveValue('Short due to refund');
|
|
});
|
|
|
|
it('should call onClose when Cancel clicked', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
const onClose = vi.fn();
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={onClose}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should close shift with correct data when Close Shift clicked', async () => {
|
|
const mockMutateAsync = vi.fn().mockResolvedValue({});
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: mockMutateAsync,
|
|
isPending: false,
|
|
});
|
|
|
|
const onSuccess = vi.fn();
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
onSuccess={onSuccess}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Enter cash count
|
|
fireEvent.change(screen.getByLabelText(/\$100 bills/i), { target: { value: '1' } });
|
|
fireEvent.change(screen.getByLabelText(/\$20 bills/i), { target: { value: '2' } });
|
|
fireEvent.change(screen.getByLabelText(/\$5 bills/i), { target: { value: '1' } });
|
|
|
|
// Add notes
|
|
const notesInput = screen.getByPlaceholderText(/notes about the shift/i);
|
|
fireEvent.change(notesInput, { target: { value: 'End of day' } });
|
|
|
|
// Submit
|
|
fireEvent.click(screen.getByRole('button', { name: /close shift/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockMutateAsync).toHaveBeenCalledWith({
|
|
shiftId: 1,
|
|
actual_balance_cents: 14500, // $145.00
|
|
cash_breakdown: {
|
|
'10000': 1, // 1x $100
|
|
'2000': 2, // 2x $20
|
|
'500': 1, // 1x $5
|
|
},
|
|
closing_notes: 'End of day',
|
|
});
|
|
});
|
|
|
|
expect(onSuccess).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should disable Close Shift button when amount is zero', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
const closeButton = screen.getByRole('button', { name: /close shift/i });
|
|
expect(closeButton).toBeDisabled();
|
|
});
|
|
|
|
it('should show loading state during submission', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: true,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
expect(screen.getByText(/closing\.\.\./i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle coin denominations correctly', () => {
|
|
mockUseCloseShift.mockReturnValue({
|
|
mutateAsync: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
|
|
render(
|
|
<CloseShiftModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
shift={mockShift}
|
|
/>,
|
|
{ wrapper: createWrapper() }
|
|
);
|
|
|
|
// Enter: 4 quarters, 10 dimes, 2 nickels, 5 pennies
|
|
// = $1.00 + $1.00 + $0.10 + $0.05 = $2.15
|
|
fireEvent.change(screen.getByLabelText(/quarters/i), { target: { value: '4' } });
|
|
fireEvent.change(screen.getByLabelText(/dimes/i), { target: { value: '10' } });
|
|
fireEvent.change(screen.getByLabelText(/nickels/i), { target: { value: '2' } });
|
|
fireEvent.change(screen.getByLabelText(/pennies/i), { target: { value: '5' } });
|
|
|
|
// Should show $2.15 in the Actual Balance section
|
|
expect(screen.getByText('Actual Balance')).toBeInTheDocument();
|
|
const actualBalanceElements = screen.getAllByText(/\$2\.15/);
|
|
expect(actualBalanceElements.length).toBeGreaterThan(0);
|
|
});
|
|
});
|