/** * Tests for ShiftReportBuilder * * ShiftReportBuilder generates shift summary reports for thermal printers. * It shows opening/closing times, cash drawer balances, sales breakdown, * variance calculations, and optional signature lines. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ShiftReportBuilder, ShiftReportConfig } from '../ShiftReportBuilder'; import type { BusinessInfo, ShiftSummary } from '../../types'; // ESC/POS command constants for verification const ESC = 0x1b; const GS = 0x1d; const LF = 0x0a; /** * Helper to create a minimal business info object */ function createBusinessInfo(overrides: Partial = {}): BusinessInfo { return { name: 'Test Business', address: '123 Main St', city: 'Testville', state: 'TS', zip: '12345', phone: '5551234567', ...overrides, }; } /** * Helper to create a minimal shift summary object */ function createShiftSummary(overrides: Partial = {}): ShiftSummary { return { id: 1, openedAt: '2024-12-26T09:00:00Z', closedAt: '2024-12-26T17:00:00Z', openedByName: 'Alice Smith', closedByName: 'Alice Smith', openingBalanceCents: 20000, expectedBalanceCents: 35000, actualBalanceCents: 35000, varianceCents: 0, cashSalesCents: 15000, cardSalesCents: 25000, giftCardSalesCents: 5000, totalSalesCents: 45000, transactionCount: 25, refundsCents: 0, ...overrides, }; } /** * Helper to create shift report config */ function createConfig(overrides: Partial = {}): ShiftReportConfig { return { includeSignatureLine: true, ...overrides, }; } describe('ShiftReportBuilder', () => { let businessInfo: BusinessInfo; let shift: ShiftSummary; let config: ShiftReportConfig; beforeEach(() => { businessInfo = createBusinessInfo(); shift = createShiftSummary(); config = createConfig(); }); describe('constructor', () => { it('should create instance with default config', () => { const builder = new ShiftReportBuilder(businessInfo, shift); expect(builder).toBeInstanceOf(ShiftReportBuilder); }); it('should accept custom config', () => { const customConfig: ShiftReportConfig = { includeSignatureLine: false, managerName: 'Manager Bob', locationName: 'Downtown Store', printedByName: 'Alice', }; const builder = new ShiftReportBuilder(businessInfo, shift, customConfig); expect(builder).toBeInstanceOf(ShiftReportBuilder); }); it('should default includeSignatureLine to true', () => { const builder = new ShiftReportBuilder(businessInfo, shift, {}); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Signature:'); }); }); describe('build()', () => { it('should return Uint8Array', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); }); it('should start with init command (ESC @)', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); expect(result[0]).toBe(ESC); expect(result[1]).toBe(0x40); }); it('should end with partial cut command', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); // Partial cut is GS V 1 const lastThreeBytes = Array.from(result.slice(-3)); expect(lastThreeBytes).toContain(GS); expect(lastThreeBytes).toContain(0x56); }); it('should contain line feeds', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const lfCount = Array.from(result).filter((b) => b === LF).length; expect(lfCount).toBeGreaterThan(10); }); }); describe('buildHeader()', () => { it('should include business name in uppercase', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain(businessInfo.name.toUpperCase()); }); it('should include location name when provided', () => { const configWithLocation = createConfig({ locationName: 'Main Street Location', }); const builder = new ShiftReportBuilder(businessInfo, shift, configWithLocation); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Main Street Location'); }); it('should skip location when not provided', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); // Should complete without error expect(result.length).toBeGreaterThan(0); }); it('should include SHIFT REPORT title', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('SHIFT REPORT'); }); }); describe('buildShiftTimes()', () => { it('should include shift ID', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Shift #:'); expect(text).toContain('1'); }); it('should include opened time', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Opened:'); }); it('should include opened by name', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Opened By:'); expect(text).toContain('Alice Smith'); }); it('should show Unknown when opened by is not provided', () => { const shiftNoOpener = createShiftSummary({ openedByName: '' }); const builder = new ShiftReportBuilder(businessInfo, shiftNoOpener, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Opened By:'); expect(text).toContain('Unknown'); }); it('should include closed time when shift is closed', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Closed:'); expect(text).toContain('Closed By:'); }); it('should include duration for closed shifts', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Duration:'); expect(text).toContain('8h'); // 9:00 to 17:00 = 8 hours }); it('should show SHIFT STILL OPEN for open shifts', () => { const openShift = createShiftSummary({ closedAt: null, closedByName: undefined, actualBalanceCents: null, varianceCents: null, }); const builder = new ShiftReportBuilder(businessInfo, openShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('*** SHIFT STILL OPEN ***'); }); it('should format time in business timezone when provided', () => { const shiftWithTz = createShiftSummary({ business_timezone: 'America/New_York', }); const builder = new ShiftReportBuilder(businessInfo, shiftWithTz, config); const result = builder.build(); expect(result.length).toBeGreaterThan(0); }); it('should show Unknown for closed by when not provided', () => { const shiftNoCloser = createShiftSummary({ closedByName: '' }); const builder = new ShiftReportBuilder(businessInfo, shiftNoCloser, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Closed By:'); expect(text).toContain('Unknown'); }); }); describe('buildOpeningBalance()', () => { it('should include CASH DRAWER section header', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('CASH DRAWER'); }); it('should show opening balance', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Opening Balance:'); expect(text).toContain('$200.00'); }); }); describe('buildSalesBreakdown()', () => { it('should include SALES SUMMARY section header', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('SALES SUMMARY'); }); it('should show transaction count', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Transactions:'); expect(text).toContain('25'); }); it('should show cash sales', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Cash Sales:'); expect(text).toContain('$150.00'); }); it('should show card sales', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Card Sales:'); expect(text).toContain('$250.00'); }); it('should show gift card sales', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Gift Card Sales:'); expect(text).toContain('$50.00'); }); it('should show total sales', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Total Sales:'); expect(text).toContain('$450.00'); }); it('should show refunds when present', () => { const shiftWithRefunds = createShiftSummary({ refundsCents: 2500, }); const builder = new ShiftReportBuilder(businessInfo, shiftWithRefunds, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Refunds:'); expect(text).toContain('-$25.00'); }); it('should skip refunds when zero', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); // Refunds should not appear when zero const refundMatches = text.match(/Refunds:/g) || []; expect(refundMatches.length).toBe(0); }); }); describe('buildCashBalance()', () => { it('should include CASH BALANCE section header', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('CASH BALANCE'); }); it('should show opening balance in calculation', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); // Opening balance should appear in cash balance section const openingMatches = text.match(/Opening Balance:/g) || []; expect(openingMatches.length).toBeGreaterThanOrEqual(2); // Once in drawer, once in balance }); it('should show cash sales in calculation', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('+ Cash Sales:'); }); it('should show expected balance', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Expected Balance:'); expect(text).toContain('$350.00'); }); it('should show actual balance when shift is closed', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Actual Balance:'); }); it('should show (Not yet counted) when actual balance is null', () => { const openShift = createShiftSummary({ closedAt: null, actualBalanceCents: null, varianceCents: null, }); const builder = new ShiftReportBuilder(businessInfo, openShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('(Not yet counted)'); }); describe('variance display', () => { it('should show zero variance correctly', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Variance:'); expect(text).toContain('$0.00'); }); it('should show Over for positive variance', () => { const shiftOver = createShiftSummary({ actualBalanceCents: 36000, varianceCents: 1000, }); const builder = new ShiftReportBuilder(businessInfo, shiftOver, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Over:'); expect(text).toContain('+$10.00'); }); it('should show Short for negative variance', () => { const shiftShort = createShiftSummary({ actualBalanceCents: 33000, varianceCents: -2000, }); const builder = new ShiftReportBuilder(businessInfo, shiftShort, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Short:'); expect(text).toContain('-$20.00'); }); it('should skip variance when null', () => { const openShift = createShiftSummary({ closedAt: null, actualBalanceCents: null, varianceCents: null, }); const builder = new ShiftReportBuilder(businessInfo, openShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).not.toContain('Variance:'); expect(text).not.toContain('Over:'); expect(text).not.toContain('Short:'); }); }); }); describe('buildSignatureLine()', () => { it('should include signature line when enabled', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Signature:'); expect(text).toContain('_____'); // Underline }); it('should skip signature line when disabled', () => { const noSigConfig = createConfig({ includeSignatureLine: false }); const builder = new ShiftReportBuilder(businessInfo, shift, noSigConfig); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).not.toContain('Signature:'); }); it('should include manager name when provided', () => { const configWithManager = createConfig({ managerName: 'Manager Carol', }); const builder = new ShiftReportBuilder( businessInfo, shift, configWithManager ); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Manager: Manager Carol'); }); it('should skip manager name when not provided', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).not.toContain('Manager:'); }); it('should include date line', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Date:'); }); }); describe('buildFooter()', () => { it('should include printed timestamp', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Printed:'); }); it('should include printed by name when provided', () => { const configWithPrinter = createConfig({ printedByName: 'Alice', }); const builder = new ShiftReportBuilder( businessInfo, shift, configWithPrinter ); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('By: Alice'); }); it('should skip printed by when not provided', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); // "By: " as a standalone line (for printed by) should not appear // Note: "Opened By:" and "Closed By:" will still be present const lines = text.split('\n'); const printedByLine = lines.find(line => line.startsWith('By:')); expect(printedByLine).toBeUndefined(); }); it('should include confidentiality notice', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('CONFIDENTIAL - FOR INTERNAL USE ONLY'); }); }); describe('duration calculation', () => { it('should show hours only when no minutes', () => { const exactHours = createShiftSummary({ openedAt: '2024-12-26T09:00:00Z', closedAt: '2024-12-26T12:00:00Z', }); const builder = new ShiftReportBuilder(businessInfo, exactHours, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Duration:'); expect(text).toContain('3h'); }); it('should show minutes only when less than 1 hour', () => { const shortShift = createShiftSummary({ openedAt: '2024-12-26T09:00:00Z', closedAt: '2024-12-26T09:45:00Z', }); const builder = new ShiftReportBuilder(businessInfo, shortShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Duration:'); expect(text).toContain('45m'); }); it('should show hours and minutes', () => { const mixedShift = createShiftSummary({ openedAt: '2024-12-26T09:00:00Z', closedAt: '2024-12-26T14:30:00Z', }); const builder = new ShiftReportBuilder(businessInfo, mixedShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Duration:'); expect(text).toContain('5h 30m'); }); it('should handle invalid dates gracefully', () => { const badDates = createShiftSummary({ openedAt: 'invalid', closedAt: 'also-invalid', }); const builder = new ShiftReportBuilder(businessInfo, badDates, config); const result = builder.build(); // Should not throw expect(result.length).toBeGreaterThan(0); }); it('should show Invalid for negative duration', () => { const backwards = createShiftSummary({ openedAt: '2024-12-26T17:00:00Z', closedAt: '2024-12-26T09:00:00Z', }); const builder = new ShiftReportBuilder(businessInfo, backwards, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Invalid'); }); }); describe('currency formatting', () => { it('should format zero correctly', () => { const zeroShift = createShiftSummary({ cashSalesCents: 0, cardSalesCents: 0, giftCardSalesCents: 0, totalSalesCents: 0, transactionCount: 0, }); const builder = new ShiftReportBuilder(businessInfo, zeroShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('$0.00'); }); it('should format large amounts correctly', () => { const bigShift = createShiftSummary({ totalSalesCents: 1000000, cashSalesCents: 500000, }); const builder = new ShiftReportBuilder(businessInfo, bigShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('$10,000.00'); expect(text).toContain('$5,000.00'); }); }); describe('chaining', () => { it('should support method chaining for all section builders', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); // Each method should return `this` const result = builder .buildHeader() .buildShiftTimes() .buildOpeningBalance() .buildSalesBreakdown() .buildCashBalance() .buildSignatureLine() .buildFooter(); expect(result).toBe(builder); }); }); describe('custom width', () => { it('should respect custom width from config', () => { const configWithWidth = createConfig({ width: 32 }); const builder = new ShiftReportBuilder(businessInfo, shift, configWithWidth); const result = builder.build(); expect(result.length).toBeGreaterThan(0); }); }); describe('complete report generation', () => { it('should generate a complete closed shift report', () => { const fullShift = createShiftSummary({ id: 42, openedAt: '2024-12-26T08:30:00Z', closedAt: '2024-12-26T17:45:00Z', openedByName: 'Bob Johnson', closedByName: 'Carol Williams', openingBalanceCents: 15000, expectedBalanceCents: 48750, actualBalanceCents: 49000, varianceCents: 250, cashSalesCents: 33750, cardSalesCents: 45000, giftCardSalesCents: 12500, totalSalesCents: 91250, transactionCount: 47, refundsCents: 1500, business_timezone: 'America/Chicago', }); const fullConfig: ShiftReportConfig = { includeSignatureLine: true, managerName: 'David Manager', locationName: 'Downtown Store', printedByName: 'Carol Williams', }; const builder = new ShiftReportBuilder(businessInfo, fullShift, fullConfig); const result = builder.build(); const text = new TextDecoder().decode(result); // Verify key sections expect(text).toContain('TEST BUSINESS'); expect(text).toContain('Downtown Store'); expect(text).toContain('SHIFT REPORT'); expect(text).toContain('Shift #:'); expect(text).toContain('42'); expect(text).toContain('Bob Johnson'); expect(text).toContain('Carol Williams'); expect(text).toContain('Duration:'); expect(text).toContain('CASH DRAWER'); expect(text).toContain('$150.00'); // Opening balance expect(text).toContain('SALES SUMMARY'); expect(text).toContain('47'); // Transaction count expect(text).toContain('$337.50'); // Cash sales expect(text).toContain('$450.00'); // Card sales expect(text).toContain('$125.00'); // Gift card sales expect(text).toContain('$912.50'); // Total sales expect(text).toContain('Refunds:'); expect(text).toContain('-$15.00'); expect(text).toContain('CASH BALANCE'); expect(text).toContain('Expected Balance:'); expect(text).toContain('Actual Balance:'); expect(text).toContain('Over:'); expect(text).toContain('+$2.50'); expect(text).toContain('Manager: David Manager'); expect(text).toContain('Signature:'); expect(text).toContain('By: Carol Williams'); expect(text).toContain('CONFIDENTIAL - FOR INTERNAL USE ONLY'); }); it('should generate a complete open shift report', () => { const openShift = createShiftSummary({ id: 43, openedAt: '2024-12-26T08:00:00Z', closedAt: null, openedByName: 'Eve Adams', closedByName: undefined, openingBalanceCents: 20000, expectedBalanceCents: 27500, actualBalanceCents: null, varianceCents: null, cashSalesCents: 7500, cardSalesCents: 15000, giftCardSalesCents: 2500, totalSalesCents: 25000, transactionCount: 12, refundsCents: 0, }); const openConfig: ShiftReportConfig = { includeSignatureLine: false, locationName: 'Mall Kiosk', }; const builder = new ShiftReportBuilder(businessInfo, openShift, openConfig); const result = builder.build(); const text = new TextDecoder().decode(result); // Verify open shift specifics expect(text).toContain('*** SHIFT STILL OPEN ***'); expect(text).toContain('(Not yet counted)'); expect(text).not.toContain('Variance:'); expect(text).not.toContain('Over:'); expect(text).not.toContain('Short:'); expect(text).not.toContain('Signature:'); }); it('should generate report with short variance', () => { const shortShift = createShiftSummary({ actualBalanceCents: 30000, expectedBalanceCents: 35000, varianceCents: -5000, }); const builder = new ShiftReportBuilder(businessInfo, shortShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('Short:'); expect(text).toContain('-$50.00'); }); it('should handle minimal shift data', () => { const minimalShift: ShiftSummary = { id: 1, openedAt: '2024-12-26T09:00:00Z', closedAt: null, openedByName: '', openingBalanceCents: 0, expectedBalanceCents: 0, actualBalanceCents: null, varianceCents: null, cashSalesCents: 0, cardSalesCents: 0, giftCardSalesCents: 0, totalSalesCents: 0, transactionCount: 0, refundsCents: 0, }; const builder = new ShiftReportBuilder(businessInfo, minimalShift, {}); const result = builder.build(); expect(result.length).toBeGreaterThan(0); }); }); describe('variance formatting', () => { it('should format positive variance with plus sign', () => { const overShift = createShiftSummary({ varianceCents: 100, actualBalanceCents: 35100, }); const builder = new ShiftReportBuilder(businessInfo, overShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('+$1.00'); }); it('should format negative variance with minus sign', () => { const shortShift = createShiftSummary({ varianceCents: -100, actualBalanceCents: 34900, }); const builder = new ShiftReportBuilder(businessInfo, shortShift, config); const result = builder.build(); const text = new TextDecoder().decode(result); expect(text).toContain('-$1.00'); }); it('should format exactly zero variance without sign', () => { const builder = new ShiftReportBuilder(businessInfo, shift, config); const result = builder.build(); const text = new TextDecoder().decode(result); // Zero variance should be displayed as simple $0.00 expect(text).toContain('$0.00'); }); }); });