diff --git a/apps/frontend/jest.config.js b/apps/frontend/jest.config.js new file mode 100644 index 0000000..fefd61b --- /dev/null +++ b/apps/frontend/jest.config.js @@ -0,0 +1,24 @@ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ dir: './' }); + +/** @type {import('jest').Config} */ +const customConfig = { + testEnvironment: 'jest-environment-jsdom', + setupFilesAfterEnv: ['/jest.setup.ts'], + testMatch: [ + '/src/**/*.{spec,test}.{ts,tsx}', + '/src/**/__tests__/**/*.{spec,test}.{ts,tsx}', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '/.next/', + '/e2e/', + ], + moduleNameMapper: { + '^@/app/(.*)$': '/app/$1', + '^@/(.*)$': '/src/$1', + }, +}; + +module.exports = createJestConfig(customConfig); diff --git a/apps/frontend/jest.setup.ts b/apps/frontend/jest.setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/apps/frontend/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/apps/frontend/src/__tests__/hooks/useCompanies.test.tsx b/apps/frontend/src/__tests__/hooks/useCompanies.test.tsx new file mode 100644 index 0000000..94bdd38 --- /dev/null +++ b/apps/frontend/src/__tests__/hooks/useCompanies.test.tsx @@ -0,0 +1,143 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCompanies } from '@/hooks/useCompanies'; +import { getAvailableCompanies } from '@/lib/api/csv-rates'; + +jest.mock('@/lib/api/csv-rates', () => ({ + getAvailableCompanies: jest.fn(), +})); + +const mockGetAvailableCompanies = jest.mocked(getAvailableCompanies); + +const MOCK_COMPANIES = ['Maersk', 'MSC', 'CMA CGM', 'Hapag-Lloyd']; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useCompanies', () => { + describe('initial state', () => { + it('starts with loading=true', () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + const { result } = renderHook(() => useCompanies()); + expect(result.current.loading).toBe(true); + }); + + it('starts with an empty companies array', () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + const { result } = renderHook(() => useCompanies()); + expect(result.current.companies).toEqual([]); + }); + + it('starts with error=null', () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + const { result } = renderHook(() => useCompanies()); + expect(result.current.error).toBeNull(); + }); + }); + + describe('on mount — success', () => { + it('fetches companies automatically on mount', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + renderHook(() => useCompanies()); + + await waitFor(() => { + expect(mockGetAvailableCompanies).toHaveBeenCalledTimes(1); + }); + }); + + it('populates companies after a successful fetch', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.companies).toEqual(MOCK_COMPANIES); + expect(result.current.error).toBeNull(); + }); + + it('handles an empty companies list', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: [], total: 0 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.companies).toEqual([]); + }); + }); + + describe('on mount — error', () => { + it('sets error when the API call fails', async () => { + mockGetAvailableCompanies.mockRejectedValue(new Error('Service unavailable')); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Service unavailable'); + expect(result.current.companies).toEqual([]); + }); + + it('uses a default error message when the error has no message', async () => { + mockGetAvailableCompanies.mockRejectedValue({}); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Failed to fetch companies'); + }); + }); + + describe('refetch', () => { + it('exposes a refetch function', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(typeof result.current.refetch).toBe('function'); + }); + + it('re-triggers the API call when refetch is invoked', async () => { + mockGetAvailableCompanies.mockResolvedValue({ companies: MOCK_COMPANIES, total: 4 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockGetAvailableCompanies).toHaveBeenCalledTimes(2); + }); + + it('updates companies with fresh data on refetch', async () => { + mockGetAvailableCompanies + .mockResolvedValueOnce({ companies: ['Maersk'], total: 1 }) + .mockResolvedValueOnce({ companies: ['Maersk', 'MSC'], total: 2 }); + + const { result } = renderHook(() => useCompanies()); + + await waitFor(() => expect(result.current.companies).toEqual(['Maersk'])); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.companies).toEqual(['Maersk', 'MSC']); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx b/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx new file mode 100644 index 0000000..37be54f --- /dev/null +++ b/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx @@ -0,0 +1,198 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCsvRateSearch } from '@/hooks/useCsvRateSearch'; +import { searchCsvRates } from '@/lib/api/csv-rates'; +import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters'; + +jest.mock('@/lib/api/csv-rates', () => ({ + searchCsvRates: jest.fn(), +})); + +const mockSearchCsvRates = jest.mocked(searchCsvRates); + +const mockRequest: CsvRateSearchRequest = { + origin: 'Le Havre', + destination: 'Shanghai', + volumeCBM: 10, + weightKG: 5000, +}; + +const mockResponse: CsvRateSearchResponse = { + results: [ + { + companyName: 'Maersk', + origin: 'Le Havre', + destination: 'Shanghai', + containerType: '40ft', + priceUSD: 2500, + priceEUR: 2300, + primaryCurrency: 'USD', + hasSurcharges: false, + surchargeDetails: null, + transitDays: 30, + validUntil: '2024-12-31', + source: 'CSV', + matchScore: 95, + }, + ], + totalResults: 1, + searchedFiles: ['maersk-rates.csv'], + searchedAt: '2024-03-01T10:00:00Z', + appliedFilters: {}, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useCsvRateSearch', () => { + describe('initial state', () => { + it('starts with data=null', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(result.current.data).toBeNull(); + }); + + it('starts with loading=false', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(result.current.loading).toBe(false); + }); + + it('starts with error=null', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(result.current.error).toBeNull(); + }); + + it('exposes a search function', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(typeof result.current.search).toBe('function'); + }); + + it('exposes a reset function', () => { + const { result } = renderHook(() => useCsvRateSearch()); + expect(typeof result.current.reset).toBe('function'); + }); + }); + + describe('search — success path', () => { + it('sets loading=true while the request is in flight', async () => { + let resolveSearch: (v: CsvRateSearchResponse) => void; + mockSearchCsvRates.mockReturnValue( + new Promise(resolve => { + resolveSearch = resolve; + }) + ); + + const { result } = renderHook(() => useCsvRateSearch()); + + act(() => { + result.current.search(mockRequest); + }); + + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveSearch!(mockResponse); + }); + }); + + it('sets data and clears loading after a successful search', async () => { + mockSearchCsvRates.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual(mockResponse); + expect(result.current.error).toBeNull(); + }); + + it('calls searchCsvRates with the given request', async () => { + mockSearchCsvRates.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(mockSearchCsvRates).toHaveBeenCalledWith(mockRequest); + }); + + it('clears a previous error when a new search starts', async () => { + mockSearchCsvRates.mockRejectedValueOnce(new Error('first error')); + mockSearchCsvRates.mockResolvedValueOnce(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + // First search fails + await act(async () => { + await result.current.search(mockRequest); + }); + expect(result.current.error).toBe('first error'); + + // Second search succeeds — error must be cleared + await act(async () => { + await result.current.search(mockRequest); + }); + expect(result.current.error).toBeNull(); + }); + }); + + describe('search — error path', () => { + it('sets error and clears data when the API throws', async () => { + mockSearchCsvRates.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('uses a default error message when the error has no message', async () => { + mockSearchCsvRates.mockRejectedValue({}); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + expect(result.current.error).toBe('Failed to search rates'); + }); + }); + + describe('reset', () => { + it('clears data, error, and loading', async () => { + mockSearchCsvRates.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useCsvRateSearch()); + + await act(async () => { + await result.current.search(mockRequest); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('can be called before any search without throwing', () => { + const { result } = renderHook(() => useCsvRateSearch()); + + expect(() => { + act(() => result.current.reset()); + }).not.toThrow(); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx b/apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx new file mode 100644 index 0000000..3c691cc --- /dev/null +++ b/apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx @@ -0,0 +1,186 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useFilterOptions } from '@/hooks/useFilterOptions'; +import { getFilterOptions } from '@/lib/api/csv-rates'; +import type { FilterOptions } from '@/types/rate-filters'; + +jest.mock('@/lib/api/csv-rates', () => ({ + getFilterOptions: jest.fn(), +})); + +const mockGetFilterOptions = jest.mocked(getFilterOptions); + +const MOCK_OPTIONS: FilterOptions = { + companies: ['Maersk', 'MSC', 'CMA CGM'], + containerTypes: ['20ft', '40ft', '40ft HC'], + currencies: ['USD', 'EUR'], +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useFilterOptions', () => { + describe('initial state', () => { + it('starts with loading=true', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.loading).toBe(true); + }); + + it('starts with empty companies array', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.companies).toEqual([]); + }); + + it('starts with empty containerTypes array', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.containerTypes).toEqual([]); + }); + + it('starts with empty currencies array', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.currencies).toEqual([]); + }); + + it('starts with error=null', () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + const { result } = renderHook(() => useFilterOptions()); + expect(result.current.error).toBeNull(); + }); + }); + + describe('on mount — success', () => { + it('fetches options automatically on mount', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + renderHook(() => useFilterOptions()); + + await waitFor(() => { + expect(mockGetFilterOptions).toHaveBeenCalledTimes(1); + }); + }); + + it('populates all option arrays after a successful fetch', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.companies).toEqual(MOCK_OPTIONS.companies); + expect(result.current.containerTypes).toEqual(MOCK_OPTIONS.containerTypes); + expect(result.current.currencies).toEqual(MOCK_OPTIONS.currencies); + }); + + it('sets loading=false after a successful fetch', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBeNull(); + }); + + it('handles an API response with empty arrays', async () => { + mockGetFilterOptions.mockResolvedValue({ + companies: [], + containerTypes: [], + currencies: [], + }); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.companies).toEqual([]); + expect(result.current.containerTypes).toEqual([]); + expect(result.current.currencies).toEqual([]); + }); + }); + + describe('on mount — error', () => { + it('sets error when the API call fails', async () => { + mockGetFilterOptions.mockRejectedValue(new Error('Gateway timeout')); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBe('Gateway timeout'); + }); + + it('uses a fallback message when the error has no message', async () => { + mockGetFilterOptions.mockRejectedValue({}); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBe('Failed to fetch filter options'); + }); + + it('preserves the empty option arrays on error', async () => { + mockGetFilterOptions.mockRejectedValue(new Error('error')); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.companies).toEqual([]); + expect(result.current.containerTypes).toEqual([]); + expect(result.current.currencies).toEqual([]); + }); + }); + + describe('refetch', () => { + it('exposes a refetch function', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(typeof result.current.refetch).toBe('function'); + }); + + it('re-triggers the fetch when refetch is invoked', async () => { + mockGetFilterOptions.mockResolvedValue(MOCK_OPTIONS); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.refetch(); + }); + + expect(mockGetFilterOptions).toHaveBeenCalledTimes(2); + }); + + it('updates options with fresh data on refetch', async () => { + const updatedOptions: FilterOptions = { + companies: ['Maersk', 'MSC', 'ONE'], + containerTypes: ['20ft', '40ft'], + currencies: ['USD'], + }; + + mockGetFilterOptions + .mockResolvedValueOnce(MOCK_OPTIONS) + .mockResolvedValueOnce(updatedOptions); + + const { result } = renderHook(() => useFilterOptions()); + + await waitFor(() => expect(result.current.companies).toEqual(MOCK_OPTIONS.companies)); + + await act(async () => { + await result.current.refetch(); + }); + + expect(result.current.companies).toEqual(updatedOptions.companies); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/lib/assets.test.ts b/apps/frontend/src/__tests__/lib/assets.test.ts new file mode 100644 index 0000000..03cd5e0 --- /dev/null +++ b/apps/frontend/src/__tests__/lib/assets.test.ts @@ -0,0 +1,86 @@ +import { + AssetPaths, + getImagePath, + getLogoPath, + getIconPath, + Images, + Logos, + Icons, +} from '@/lib/assets'; + +describe('AssetPaths constants', () => { + it('has the correct images base path', () => { + expect(AssetPaths.images).toBe('/assets/images'); + }); + + it('has the correct logos base path', () => { + expect(AssetPaths.logos).toBe('/assets/logos'); + }); + + it('has the correct icons base path', () => { + expect(AssetPaths.icons).toBe('/assets/icons'); + }); +}); + +describe('getImagePath', () => { + it('returns the correct full path for a given filename', () => { + expect(getImagePath('hero-banner.jpg')).toBe('/assets/images/hero-banner.jpg'); + }); + + it('handles filenames without extension', () => { + expect(getImagePath('background')).toBe('/assets/images/background'); + }); + + it('handles filenames with multiple dots', () => { + expect(getImagePath('my.image.v2.png')).toBe('/assets/images/my.image.v2.png'); + }); + + it('starts with a slash', () => { + expect(getImagePath('test.jpg')).toMatch(/^\//); + }); +}); + +describe('getLogoPath', () => { + it('returns the correct full path for a logo', () => { + expect(getLogoPath('xpeditis-logo.svg')).toBe('/assets/logos/xpeditis-logo.svg'); + }); + + it('handles a dark variant logo', () => { + expect(getLogoPath('xpeditis-logo-dark.svg')).toBe('/assets/logos/xpeditis-logo-dark.svg'); + }); + + it('starts with a slash', () => { + expect(getLogoPath('icon.svg')).toMatch(/^\//); + }); +}); + +describe('getIconPath', () => { + it('returns the correct full path for an icon', () => { + expect(getIconPath('shipping-icon.svg')).toBe('/assets/icons/shipping-icon.svg'); + }); + + it('handles a PNG icon', () => { + expect(getIconPath('notification.png')).toBe('/assets/icons/notification.png'); + }); + + it('starts with a slash', () => { + expect(getIconPath('arrow.svg')).toMatch(/^\//); + }); +}); + +describe('pre-defined asset collections', () => { + it('Images is a defined object', () => { + expect(Images).toBeDefined(); + expect(typeof Images).toBe('object'); + }); + + it('Logos is a defined object', () => { + expect(Logos).toBeDefined(); + expect(typeof Logos).toBe('object'); + }); + + it('Icons is a defined object', () => { + expect(Icons).toBeDefined(); + expect(typeof Icons).toBe('object'); + }); +}); diff --git a/apps/frontend/src/__tests__/lib/utils.test.ts b/apps/frontend/src/__tests__/lib/utils.test.ts new file mode 100644 index 0000000..0eceef5 --- /dev/null +++ b/apps/frontend/src/__tests__/lib/utils.test.ts @@ -0,0 +1,78 @@ +import { cn } from '@/lib/utils'; + +describe('cn — class name merger', () => { + describe('basic merging', () => { + it('returns an empty string when called with no arguments', () => { + expect(cn()).toBe(''); + }); + + it('returns the class when given a single string', () => { + expect(cn('foo')).toBe('foo'); + }); + + it('joins multiple class strings with a space', () => { + expect(cn('foo', 'bar', 'baz')).toBe('foo bar baz'); + }); + + it('ignores falsy values', () => { + expect(cn('foo', undefined, null, false, 'bar')).toBe('foo bar'); + }); + + it('handles an empty string argument', () => { + expect(cn('', 'foo')).toBe('foo'); + }); + }); + + describe('conditional classes', () => { + it('includes a class when its condition is true', () => { + expect(cn('base', true && 'active')).toBe('base active'); + }); + + it('excludes a class when its condition is false', () => { + expect(cn('base', false && 'active')).toBe('base'); + }); + + it('supports object syntax — includes keys whose value is truthy', () => { + expect(cn({ foo: true, bar: false, baz: true })).toBe('foo baz'); + }); + + it('supports array syntax', () => { + expect(cn(['foo', 'bar'])).toBe('foo bar'); + }); + + it('supports mixed input types', () => { + expect(cn('base', { active: true, disabled: false }, ['extra'])).toBe('base active extra'); + }); + }); + + describe('Tailwind conflict resolution', () => { + it('resolves padding conflicts — last padding wins', () => { + expect(cn('p-4', 'p-8')).toBe('p-8'); + }); + + it('resolves text-size conflicts — last size wins', () => { + expect(cn('text-sm', 'text-lg')).toBe('text-lg'); + }); + + it('resolves background-color conflicts', () => { + expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500'); + }); + + it('keeps non-conflicting utility classes', () => { + const result = cn('p-4', 'text-sm', 'font-bold'); + expect(result).toContain('p-4'); + expect(result).toContain('text-sm'); + expect(result).toContain('font-bold'); + }); + + it('resolves margin conflicts', () => { + expect(cn('mt-2', 'mt-4')).toBe('mt-4'); + }); + + it('does not remove classes that do not conflict', () => { + expect(cn('flex', 'items-center', 'justify-between')).toBe( + 'flex items-center justify-between' + ); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/setup.ts b/apps/frontend/src/__tests__/setup.ts new file mode 100644 index 0000000..2b906b2 --- /dev/null +++ b/apps/frontend/src/__tests__/setup.ts @@ -0,0 +1,3 @@ +// This file is intentionally empty — the real setup is in jest.setup.ts at the root. +// It exists only to avoid breaking imports. Jest will skip it (no tests inside). +export {}; diff --git a/apps/frontend/src/__tests__/types/booking.test.ts b/apps/frontend/src/__tests__/types/booking.test.ts new file mode 100644 index 0000000..a890319 --- /dev/null +++ b/apps/frontend/src/__tests__/types/booking.test.ts @@ -0,0 +1,94 @@ +import { + BookingStatus, + ContainerType, + ExportFormat, +} from '@/types/booking'; + +describe('BookingStatus enum', () => { + it('has DRAFT value', () => { + expect(BookingStatus.DRAFT).toBe('draft'); + }); + + it('has CONFIRMED value', () => { + expect(BookingStatus.CONFIRMED).toBe('confirmed'); + }); + + it('has IN_PROGRESS value', () => { + expect(BookingStatus.IN_PROGRESS).toBe('in_progress'); + }); + + it('has COMPLETED value', () => { + expect(BookingStatus.COMPLETED).toBe('completed'); + }); + + it('has CANCELLED value', () => { + expect(BookingStatus.CANCELLED).toBe('cancelled'); + }); + + it('has exactly 5 statuses', () => { + const values = Object.values(BookingStatus); + expect(values).toHaveLength(5); + }); + + it('all values are lowercase strings', () => { + Object.values(BookingStatus).forEach(v => { + expect(v).toBe(v.toLowerCase()); + }); + }); +}); + +describe('ContainerType enum', () => { + it('has DRY_20 value', () => { + expect(ContainerType.DRY_20).toBe('20ft'); + }); + + it('has DRY_40 value', () => { + expect(ContainerType.DRY_40).toBe('40ft'); + }); + + it('has HIGH_CUBE_40 value', () => { + expect(ContainerType.HIGH_CUBE_40).toBe('40ft HC'); + }); + + it('has REEFER_20 value', () => { + expect(ContainerType.REEFER_20).toBe('20ft Reefer'); + }); + + it('has REEFER_40 value', () => { + expect(ContainerType.REEFER_40).toBe('40ft Reefer'); + }); + + it('has exactly 5 container types', () => { + expect(Object.values(ContainerType)).toHaveLength(5); + }); + + it('all standard (non-reefer) values start with a size prefix', () => { + expect(ContainerType.DRY_20).toMatch(/^\d+ft/); + expect(ContainerType.DRY_40).toMatch(/^\d+ft/); + expect(ContainerType.HIGH_CUBE_40).toMatch(/^\d+ft/); + }); +}); + +describe('ExportFormat enum', () => { + it('has CSV value', () => { + expect(ExportFormat.CSV).toBe('csv'); + }); + + it('has EXCEL value', () => { + expect(ExportFormat.EXCEL).toBe('excel'); + }); + + it('has JSON value', () => { + expect(ExportFormat.JSON).toBe('json'); + }); + + it('has exactly 3 formats', () => { + expect(Object.values(ExportFormat)).toHaveLength(3); + }); + + it('all values are lowercase', () => { + Object.values(ExportFormat).forEach(v => { + expect(v).toBe(v.toLowerCase()); + }); + }); +}); diff --git a/apps/frontend/src/__tests__/utils/export.test.ts b/apps/frontend/src/__tests__/utils/export.test.ts new file mode 100644 index 0000000..cbc2835 --- /dev/null +++ b/apps/frontend/src/__tests__/utils/export.test.ts @@ -0,0 +1,345 @@ +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'); + }); +});