import { exportToCSV, exportToExcel, exportToJSON, exportBookings, ExportField } from '@/utils/export'; import { Booking, BookingStatus, ContainerType } from '@/types/booking'; // ── Mocks ───────────────────────────────────────────────────────────────────── const mockSaveAs = jest.fn(); jest.mock('file-saver', () => ({ saveAs: (...args: unknown[]) => mockSaveAs(...args), })); const mockAoaToSheet = jest.fn().mockReturnValue({ '!ref': 'A1:K2' }); const mockBookNew = jest.fn().mockReturnValue({}); const mockBookAppendSheet = jest.fn(); const mockWrite = jest.fn().mockReturnValue(new ArrayBuffer(8)); jest.mock('xlsx', () => ({ utils: { aoa_to_sheet: (...args: unknown[]) => mockAoaToSheet(...args), book_new: () => mockBookNew(), book_append_sheet: (...args: unknown[]) => mockBookAppendSheet(...args), }, write: (...args: unknown[]) => mockWrite(...args), })); // ── Blob capture helper ──────────────────────────────────────────────────────── // blob.text() is not available in all jsdom versions; instead we intercept the // Blob constructor to capture the raw string before it's wrapped. const OriginalBlob = global.Blob; let capturedBlobParts: string[] = []; beforeEach(() => { jest.clearAllMocks(); capturedBlobParts = []; global.Blob = jest.fn().mockImplementation( (parts?: BlobPart[], options?: BlobPropertyBag) => { const content = (parts ?? []).map(p => (typeof p === 'string' ? p : '')).join(''); capturedBlobParts.push(content); return { type: options?.type ?? '', size: content.length } as Blob; } ) as unknown as typeof Blob; }); afterEach(() => { global.Blob = OriginalBlob; }); // ── Fixtures ────────────────────────────────────────────────────────────────── const makeBooking = (overrides: Partial = {}): Booking => ({ id: 'b-1', bookingNumber: 'WCM-2024-ABC001', status: BookingStatus.CONFIRMED, shipper: { name: 'Acme Corp', street: '1 rue de la Paix', city: 'Paris', postalCode: '75001', country: 'France', }, consignee: { name: 'Beta Ltd', street: '42 Main St', city: 'Shanghai', postalCode: '200000', country: 'China', }, containers: [ { id: 'c-1', type: ContainerType.DRY_40 }, { id: 'c-2', type: ContainerType.HIGH_CUBE_40 }, ], rateQuote: { id: 'rq-1', carrierName: 'Maersk', carrierScac: 'MAEU', origin: 'Le Havre', destination: 'Shanghai', priceValue: 2500, priceCurrency: 'USD', etd: '2024-03-01T00:00:00Z', eta: '2024-04-01T00:00:00Z', transitDays: 31, }, createdAt: '2024-01-15T10:00:00Z', updatedAt: '2024-01-15T10:00:00Z', ...overrides, }); // ── Tests ───────────────────────────────────────────────────────────────────── describe('exportToCSV', () => { it('calls saveAs once', () => { exportToCSV([makeBooking()]); expect(mockSaveAs).toHaveBeenCalledTimes(1); }); it('passes a Blob as the first saveAs argument', () => { exportToCSV([makeBooking()]); const [blob] = mockSaveAs.mock.calls[0]; expect(blob).toBeDefined(); expect(blob.type).toContain('text/csv'); }); it('uses the default filename', () => { exportToCSV([makeBooking()]); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('bookings-export.csv'); }); it('uses a custom filename when provided', () => { exportToCSV([makeBooking()], undefined, 'my-export.csv'); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('my-export.csv'); }); it('generates a CSV header with default field labels', () => { exportToCSV([makeBooking()]); const csv = capturedBlobParts[0]; expect(csv).toContain('Booking Number'); expect(csv).toContain('Status'); expect(csv).toContain('Carrier'); expect(csv).toContain('Origin'); expect(csv).toContain('Destination'); }); it('includes booking data in the CSV rows', () => { exportToCSV([makeBooking()]); const csv = capturedBlobParts[0]; expect(csv).toContain('WCM-2024-ABC001'); expect(csv).toContain('confirmed'); expect(csv).toContain('Maersk'); expect(csv).toContain('Le Havre'); expect(csv).toContain('Shanghai'); }); it('applies custom fields and their labels', () => { const customFields: ExportField[] = [ { key: 'bookingNumber', label: 'Number' }, { key: 'status', label: 'State' }, ]; exportToCSV([makeBooking()], customFields); const csv = capturedBlobParts[0]; expect(csv).toContain('Number'); expect(csv).toContain('State'); expect(csv).not.toContain('Carrier'); }); it('applies field formatters', () => { const customFields: ExportField[] = [ { key: 'status', label: 'Status', formatter: (v: string) => v.toUpperCase() }, ]; exportToCSV([makeBooking()], customFields); expect(capturedBlobParts[0]).toContain('CONFIRMED'); }); it('extracts nested values with dot-notation keys', () => { const customFields: ExportField[] = [ { key: 'rateQuote.carrierName', label: 'Carrier' }, { key: 'shipper.name', label: 'Shipper' }, ]; exportToCSV([makeBooking()], customFields); const csv = capturedBlobParts[0]; expect(csv).toContain('Maersk'); expect(csv).toContain('Acme Corp'); }); it('extracts deeply nested values', () => { const customFields: ExportField[] = [ { key: 'consignee.city', label: 'Consignee City' }, ]; exportToCSV([makeBooking()], customFields); expect(capturedBlobParts[0]).toContain('Shanghai'); }); it('generates only the header row when data is empty', () => { exportToCSV([]); const lines = capturedBlobParts[0].split('\n'); expect(lines).toHaveLength(1); }); it('generates one data row per booking', () => { exportToCSV([ makeBooking(), makeBooking({ id: 'b-2', bookingNumber: 'WCM-2024-ABC002' }), ]); const lines = capturedBlobParts[0].trim().split('\n'); expect(lines).toHaveLength(3); // header + 2 rows }); it('wraps all cell values in double quotes', () => { const customFields: ExportField[] = [ { key: 'bookingNumber', label: 'Number' }, ]; exportToCSV([makeBooking()], customFields); const dataLine = capturedBlobParts[0].split('\n')[1]; expect(dataLine).toMatch(/^".*"$/); }); it('escapes double quotes inside cell values', () => { const customFields: ExportField[] = [ { key: 'shipper.name', label: 'Shipper' }, ]; const booking = makeBooking({ shipper: { name: 'He said "hello"', street: '1 st', city: 'Paris', postalCode: '75001', country: 'France', }, }); exportToCSV([booking], customFields); // Original `"` should be escaped as `""` expect(capturedBlobParts[0]).toContain('He said ""hello""'); }); it('returns undefined', () => { expect(exportToCSV([makeBooking()])).toBeUndefined(); }); }); describe('exportToExcel', () => { it('calls saveAs with the default filename', () => { exportToExcel([makeBooking()]); expect(mockSaveAs).toHaveBeenCalledTimes(1); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('bookings-export.xlsx'); }); it('uses a custom filename', () => { exportToExcel([makeBooking()], undefined, 'report.xlsx'); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('report.xlsx'); }); it('calls aoa_to_sheet with worksheet data', () => { exportToExcel([makeBooking()]); expect(mockAoaToSheet).toHaveBeenCalledTimes(1); const [wsData] = mockAoaToSheet.mock.calls[0]; expect(Array.isArray(wsData[0])).toBe(true); }); it('places the header labels in the first row', () => { exportToExcel([makeBooking()]); const [wsData] = mockAoaToSheet.mock.calls[0]; const headers = wsData[0]; expect(headers).toContain('Booking Number'); expect(headers).toContain('Carrier'); expect(headers).toContain('Status'); }); it('creates a new workbook', () => { exportToExcel([makeBooking()]); expect(mockBookNew).toHaveBeenCalledTimes(1); }); it('appends the worksheet with the name "Bookings"', () => { exportToExcel([makeBooking()]); expect(mockBookAppendSheet).toHaveBeenCalledTimes(1); const [, , sheetName] = mockBookAppendSheet.mock.calls[0]; expect(sheetName).toBe('Bookings'); }); it('calls XLSX.write with bookType "xlsx"', () => { exportToExcel([makeBooking()]); expect(mockWrite).toHaveBeenCalledTimes(1); const [, opts] = mockWrite.mock.calls[0]; expect(opts.bookType).toBe('xlsx'); }); it('produces a row for each booking (plus one header)', () => { exportToExcel([makeBooking(), makeBooking({ id: 'b-2' })]); const [wsData] = mockAoaToSheet.mock.calls[0]; expect(wsData).toHaveLength(3); // 1 header + 2 data rows }); }); describe('exportToJSON', () => { it('calls saveAs with the default filename', () => { exportToJSON([makeBooking()]); expect(mockSaveAs).toHaveBeenCalledTimes(1); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('bookings-export.json'); }); it('uses a custom filename', () => { exportToJSON([makeBooking()], 'data.json'); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('data.json'); }); it('creates a Blob with application/json type', () => { exportToJSON([makeBooking()]); const [blob] = mockSaveAs.mock.calls[0]; expect(blob.type).toContain('application/json'); }); it('serialises bookings as valid JSON', () => { const booking = makeBooking(); exportToJSON([booking]); const json = capturedBlobParts[0]; const parsed = JSON.parse(json); expect(Array.isArray(parsed)).toBe(true); expect(parsed[0].bookingNumber).toBe('WCM-2024-ABC001'); }); it('produces pretty-printed JSON (2-space indent)', () => { exportToJSON([makeBooking()]); expect(capturedBlobParts[0]).toContain('\n '); }); }); describe('exportBookings dispatcher', () => { it('routes "csv" to exportToCSV', () => { exportBookings([makeBooking()], 'csv'); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('bookings-export.csv'); }); it('routes "excel" to exportToExcel', () => { exportBookings([makeBooking()], 'excel'); expect(mockAoaToSheet).toHaveBeenCalledTimes(1); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('bookings-export.xlsx'); }); it('routes "json" to exportToJSON', () => { exportBookings([makeBooking()], 'json'); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('bookings-export.json'); }); it('throws for an unknown format', () => { expect(() => exportBookings([makeBooking()], 'pdf' as any)).toThrow( 'Unsupported export format: pdf' ); }); it('passes a custom filename through to the underlying exporter', () => { exportBookings([makeBooking()], 'csv', undefined, 'custom.csv'); const [, filename] = mockSaveAs.mock.calls[0]; expect(filename).toBe('custom.csv'); }); });