/** * 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 }) => ( {children} ); }; // 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(); }); });