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>
697 lines
18 KiB
TypeScript
697 lines
18 KiB
TypeScript
/**
|
|
* Tests for POSContext operations
|
|
*
|
|
* Tests cover cart operations including adding items, removing items,
|
|
* updating quantities, applying discounts, and calculating totals.
|
|
*/
|
|
|
|
import React, { type ReactNode } from 'react';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { POSProvider, usePOS } from '../context/POSContext';
|
|
import type { POSProduct, POSService, POSDiscount } from '../types';
|
|
|
|
// Clear localStorage before each test to prevent state leakage
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
// Create wrapper component for hooks
|
|
const createWrapper = (initialLocationId?: number) => {
|
|
return ({ children }: { children: ReactNode }) => (
|
|
<POSProvider initialLocationId={initialLocationId ?? null}>{children}</POSProvider>
|
|
);
|
|
};
|
|
|
|
// Mock product - matches POSProduct interface from types.ts
|
|
const mockProduct: POSProduct = {
|
|
id: 1,
|
|
name: 'Test Product',
|
|
sku: 'TEST-001',
|
|
barcode: '123456789',
|
|
description: 'A test product',
|
|
price_cents: 1000,
|
|
cost_cents: 500,
|
|
tax_rate: 0.08,
|
|
is_taxable: true,
|
|
category_id: 1,
|
|
display_order: 1,
|
|
image_url: null,
|
|
color: '#3B82F6',
|
|
status: 'active',
|
|
track_inventory: true,
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
};
|
|
|
|
// Mock service - matches POSService interface from types.ts
|
|
const mockService: POSService = {
|
|
id: 2,
|
|
name: 'Test Service',
|
|
description: 'A test service',
|
|
price_cents: 2500,
|
|
duration_minutes: 60,
|
|
};
|
|
|
|
describe('POSContext - Initial State', () => {
|
|
it('should start with empty cart', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(result.current.state.cart.items).toHaveLength(0);
|
|
expect(result.current.isCartEmpty).toBe(true);
|
|
expect(result.current.itemCount).toBe(0);
|
|
});
|
|
|
|
it('should start with zero totals', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(result.current.state.cart.subtotalCents).toBe(0);
|
|
expect(result.current.state.cart.taxCents).toBe(0);
|
|
expect(result.current.state.cart.tipCents).toBe(0);
|
|
expect(result.current.state.cart.discountCents).toBe(0);
|
|
expect(result.current.state.cart.totalCents).toBe(0);
|
|
});
|
|
|
|
it('should accept initial location ID', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(42),
|
|
});
|
|
|
|
expect(result.current.state.selectedLocationId).toBe(42);
|
|
});
|
|
|
|
it('should start with no customer', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(result.current.state.cart.customer).toBeNull();
|
|
});
|
|
|
|
it('should start with no active shift', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(result.current.state.activeShift).toBeNull();
|
|
});
|
|
|
|
it('should start with printer disconnected', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
expect(result.current.state.printerStatus).toBe('disconnected');
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Adding Items', () => {
|
|
it('should add a product to cart', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
expect(result.current.state.cart.items).toHaveLength(1);
|
|
expect(result.current.state.cart.items[0].name).toBe('Test Product');
|
|
expect(result.current.state.cart.items[0].unitPriceCents).toBe(1000);
|
|
expect(result.current.state.cart.items[0].quantity).toBe(1);
|
|
});
|
|
|
|
it('should add a service to cart', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockService, 1, 'service');
|
|
});
|
|
|
|
expect(result.current.state.cart.items).toHaveLength(1);
|
|
expect(result.current.state.cart.items[0].name).toBe('Test Service');
|
|
expect(result.current.state.cart.items[0].unitPriceCents).toBe(2500);
|
|
expect(result.current.state.cart.items[0].itemType).toBe('service');
|
|
});
|
|
|
|
it('should increment quantity for existing item', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
// Should still be one item with quantity 2
|
|
expect(result.current.state.cart.items).toHaveLength(1);
|
|
expect(result.current.state.cart.items[0].quantity).toBe(2);
|
|
});
|
|
|
|
it('should add multiple quantities at once', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 5, 'product');
|
|
});
|
|
|
|
expect(result.current.state.cart.items[0].quantity).toBe(5);
|
|
expect(result.current.itemCount).toBe(5);
|
|
});
|
|
|
|
it('should update item count correctly', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product');
|
|
result.current.addItem(mockService, 1, 'service');
|
|
});
|
|
|
|
expect(result.current.itemCount).toBe(3);
|
|
expect(result.current.isCartEmpty).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Removing Items', () => {
|
|
it('should remove item from cart', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
const itemId = result.current.state.cart.items[0].id;
|
|
|
|
act(() => {
|
|
result.current.removeItem(itemId);
|
|
});
|
|
|
|
expect(result.current.state.cart.items).toHaveLength(0);
|
|
expect(result.current.isCartEmpty).toBe(true);
|
|
});
|
|
|
|
it('should handle removing non-existent item gracefully', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
act(() => {
|
|
result.current.removeItem('non-existent-id');
|
|
});
|
|
|
|
// Should still have the original item
|
|
expect(result.current.state.cart.items).toHaveLength(1);
|
|
});
|
|
|
|
it('should recalculate totals after removal', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
result.current.addItem(mockService, 1, 'service');
|
|
});
|
|
|
|
const productItemId = result.current.state.cart.items[0].id;
|
|
|
|
act(() => {
|
|
result.current.removeItem(productItemId);
|
|
});
|
|
|
|
// Should only have service now
|
|
expect(result.current.state.cart.subtotalCents).toBe(2500);
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Updating Quantities', () => {
|
|
it('should update item quantity', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
const itemId = result.current.state.cart.items[0].id;
|
|
|
|
act(() => {
|
|
result.current.updateQuantity(itemId, 5);
|
|
});
|
|
|
|
expect(result.current.state.cart.items[0].quantity).toBe(5);
|
|
expect(result.current.itemCount).toBe(5);
|
|
});
|
|
|
|
it('should remove item when quantity set to zero', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 3, 'product');
|
|
});
|
|
|
|
const itemId = result.current.state.cart.items[0].id;
|
|
|
|
act(() => {
|
|
result.current.updateQuantity(itemId, 0);
|
|
});
|
|
|
|
expect(result.current.state.cart.items).toHaveLength(0);
|
|
});
|
|
|
|
it('should remove item when quantity is negative', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product');
|
|
});
|
|
|
|
const itemId = result.current.state.cart.items[0].id;
|
|
|
|
act(() => {
|
|
result.current.updateQuantity(itemId, -1);
|
|
});
|
|
|
|
expect(result.current.state.cart.items).toHaveLength(0);
|
|
});
|
|
|
|
it('should recalculate totals after quantity update', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
const initialSubtotal = result.current.state.cart.subtotalCents;
|
|
const itemId = result.current.state.cart.items[0].id;
|
|
|
|
act(() => {
|
|
result.current.updateQuantity(itemId, 3);
|
|
});
|
|
|
|
expect(result.current.state.cart.subtotalCents).toBe(initialSubtotal * 3);
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Applying Discounts', () => {
|
|
it('should apply percentage discount to cart', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product');
|
|
});
|
|
|
|
const discount: POSDiscount = {
|
|
percent: 10,
|
|
reason: 'Loyalty discount',
|
|
};
|
|
|
|
act(() => {
|
|
result.current.applyDiscount(discount);
|
|
});
|
|
|
|
expect(result.current.state.cart.discount).toEqual(discount);
|
|
// 10% of $20.00 = $2.00 = 200 cents
|
|
expect(result.current.state.cart.discountCents).toBe(200);
|
|
});
|
|
|
|
it('should apply fixed amount discount to cart', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product');
|
|
});
|
|
|
|
const discount: POSDiscount = {
|
|
amountCents: 500,
|
|
reason: 'Promo code',
|
|
};
|
|
|
|
act(() => {
|
|
result.current.applyDiscount(discount);
|
|
});
|
|
|
|
expect(result.current.state.cart.discountCents).toBe(500);
|
|
});
|
|
|
|
it('should clear discount', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product');
|
|
result.current.applyDiscount({ percent: 10 });
|
|
});
|
|
|
|
act(() => {
|
|
result.current.clearDiscount();
|
|
});
|
|
|
|
expect(result.current.state.cart.discount).toBeNull();
|
|
expect(result.current.state.cart.discountCents).toBe(0);
|
|
});
|
|
|
|
it('should apply item-level discount', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
const itemId = result.current.state.cart.items[0].id;
|
|
|
|
act(() => {
|
|
result.current.setItemDiscount(itemId, undefined, 20); // 20% off
|
|
});
|
|
|
|
expect(result.current.state.cart.items[0].discountPercent).toBe(20);
|
|
// Subtotal should reflect the discount
|
|
expect(result.current.state.cart.subtotalCents).toBe(800); // $10 - 20% = $8
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Setting Tip', () => {
|
|
it('should set tip amount', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setTip(200);
|
|
});
|
|
|
|
expect(result.current.state.cart.tipCents).toBe(200);
|
|
});
|
|
|
|
it('should include tip in total', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
});
|
|
|
|
const totalBeforeTip = result.current.state.cart.totalCents;
|
|
|
|
act(() => {
|
|
result.current.setTip(300);
|
|
});
|
|
|
|
expect(result.current.state.cart.totalCents).toBe(totalBeforeTip + 300);
|
|
});
|
|
|
|
it('should handle zero tip', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product');
|
|
result.current.setTip(500);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setTip(0);
|
|
});
|
|
|
|
expect(result.current.state.cart.tipCents).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Calculating Totals', () => {
|
|
it('should calculate subtotal correctly', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product'); // $10 x 2 = $20
|
|
result.current.addItem(mockService, 1, 'service'); // $25
|
|
});
|
|
|
|
expect(result.current.state.cart.subtotalCents).toBe(4500); // $45
|
|
});
|
|
|
|
it('should calculate tax correctly', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product'); // $10 @ 8% = $0.80 tax
|
|
});
|
|
|
|
expect(result.current.state.cart.taxCents).toBe(80);
|
|
});
|
|
|
|
it('should calculate total correctly', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product'); // $10 + $0.80 tax
|
|
});
|
|
|
|
expect(result.current.state.cart.totalCents).toBe(1080);
|
|
});
|
|
|
|
it('should calculate total with discount and tip', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product'); // $20 subtotal + $1.60 tax
|
|
result.current.applyDiscount({ amountCents: 500 }); // -$5 discount
|
|
result.current.setTip(300); // +$3 tip
|
|
});
|
|
|
|
// Total: $20 + $1.60 - $5 + $3 = $19.60
|
|
expect(result.current.state.cart.totalCents).toBe(1960);
|
|
});
|
|
|
|
it('should not allow negative total', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 1, 'product'); // $10.80 total
|
|
result.current.applyDiscount({ amountCents: 2000 }); // $20 discount (exceeds total)
|
|
});
|
|
|
|
expect(result.current.state.cart.totalCents).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Customer Management', () => {
|
|
it('should set customer', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
const customer = {
|
|
id: 1,
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
phone: '555-123-4567',
|
|
};
|
|
|
|
act(() => {
|
|
result.current.setCustomer(customer);
|
|
});
|
|
|
|
expect(result.current.state.cart.customer).toEqual(customer);
|
|
});
|
|
|
|
it('should clear customer', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setCustomer({ id: 1, name: 'John', email: '', phone: '' });
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setCustomer(null);
|
|
});
|
|
|
|
expect(result.current.state.cart.customer).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Clearing Cart', () => {
|
|
it('should clear all items from cart', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.addItem(mockProduct, 2, 'product');
|
|
result.current.addItem(mockService, 1, 'service');
|
|
result.current.setTip(500);
|
|
result.current.applyDiscount({ percent: 10 });
|
|
});
|
|
|
|
act(() => {
|
|
result.current.clearCart();
|
|
});
|
|
|
|
expect(result.current.state.cart.items).toHaveLength(0);
|
|
expect(result.current.state.cart.subtotalCents).toBe(0);
|
|
expect(result.current.state.cart.taxCents).toBe(0);
|
|
expect(result.current.state.cart.tipCents).toBe(0);
|
|
expect(result.current.state.cart.discountCents).toBe(0);
|
|
expect(result.current.state.cart.totalCents).toBe(0);
|
|
expect(result.current.state.cart.discount).toBeNull();
|
|
expect(result.current.state.cart.customer).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Active Shift', () => {
|
|
it('should set active shift', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
const shift = {
|
|
id: 1,
|
|
location: 1,
|
|
opened_by: 1,
|
|
closed_by: null,
|
|
opening_balance_cents: 10000,
|
|
expected_balance_cents: 15000,
|
|
actual_balance_cents: null,
|
|
variance_cents: null,
|
|
cash_breakdown: {},
|
|
status: 'open' as const,
|
|
opened_at: '2024-01-01T09:00:00Z',
|
|
closed_at: null,
|
|
opening_notes: '',
|
|
closing_notes: '',
|
|
};
|
|
|
|
act(() => {
|
|
result.current.setActiveShift(shift);
|
|
});
|
|
|
|
expect(result.current.state.activeShift).toEqual(shift);
|
|
});
|
|
|
|
it('should clear active shift', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setActiveShift({ id: 1 } as any);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setActiveShift(null);
|
|
});
|
|
|
|
expect(result.current.state.activeShift).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Printer Status', () => {
|
|
it('should update printer status', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setPrinterStatus('connecting');
|
|
});
|
|
|
|
expect(result.current.state.printerStatus).toBe('connecting');
|
|
|
|
act(() => {
|
|
result.current.setPrinterStatus('connected');
|
|
});
|
|
|
|
expect(result.current.state.printerStatus).toBe('connected');
|
|
});
|
|
});
|
|
|
|
describe('POSContext - Location', () => {
|
|
it('should set location', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setLocation(123);
|
|
});
|
|
|
|
expect(result.current.state.selectedLocationId).toBe(123);
|
|
});
|
|
|
|
it('should clear location', () => {
|
|
const { result } = renderHook(() => usePOS(), {
|
|
wrapper: createWrapper(42),
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setLocation(null);
|
|
});
|
|
|
|
expect(result.current.state.selectedLocationId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('POSContext - usePOS Hook Error', () => {
|
|
it('should throw error when used outside provider', () => {
|
|
// Suppress console.error for this test
|
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
expect(() => {
|
|
renderHook(() => usePOS());
|
|
}).toThrow('usePOS must be used within a POSProvider');
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
});
|