Some checks failed
Dev CI / Notify Failure (push) Blocked by required conditions
Dev CI / Frontend — Lint & Type-check (push) Failing after 6m16s
Dev CI / Frontend — Unit Tests (push) Has been skipped
Dev CI / Backend — Lint (push) Successful in 10m24s
Dev CI / Backend — Unit Tests (push) Has been cancelled
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
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> = {}): 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');
|
|
});
|
|
});
|