Files
smoothschedule/frontend/src/pos/__tests__/POSContext.test.tsx
poduck 1aa5b76e3b Add Point of Sale system and tax rate lookup integration
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>
2025-12-27 11:31:19 -05:00

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();
});
});