fix test
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
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
This commit is contained in:
parent
62698de952
commit
fca1cf051a
24
apps/frontend/jest.config.js
Normal file
24
apps/frontend/jest.config.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const nextJest = require('next/jest');
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({ dir: './' });
|
||||||
|
|
||||||
|
/** @type {import('jest').Config} */
|
||||||
|
const customConfig = {
|
||||||
|
testEnvironment: 'jest-environment-jsdom',
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
|
testMatch: [
|
||||||
|
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}',
|
||||||
|
'<rootDir>/src/**/__tests__/**/*.{spec,test}.{ts,tsx}',
|
||||||
|
],
|
||||||
|
testPathIgnorePatterns: [
|
||||||
|
'<rootDir>/node_modules/',
|
||||||
|
'<rootDir>/.next/',
|
||||||
|
'<rootDir>/e2e/',
|
||||||
|
],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/app/(.*)$': '<rootDir>/app/$1',
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = createJestConfig(customConfig);
|
||||||
1
apps/frontend/jest.setup.ts
Normal file
1
apps/frontend/jest.setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
143
apps/frontend/src/__tests__/hooks/useCompanies.test.tsx
Normal file
143
apps/frontend/src/__tests__/hooks/useCompanies.test.tsx
Normal file
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
198
apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx
Normal file
198
apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx
Normal file
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
186
apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx
Normal file
186
apps/frontend/src/__tests__/hooks/useFilterOptions.test.tsx
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
86
apps/frontend/src/__tests__/lib/assets.test.ts
Normal file
86
apps/frontend/src/__tests__/lib/assets.test.ts
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/frontend/src/__tests__/lib/utils.test.ts
Normal file
78
apps/frontend/src/__tests__/lib/utils.test.ts
Normal file
@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
3
apps/frontend/src/__tests__/setup.ts
Normal file
3
apps/frontend/src/__tests__/setup.ts
Normal file
@ -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 {};
|
||||||
94
apps/frontend/src/__tests__/types/booking.test.ts
Normal file
94
apps/frontend/src/__tests__/types/booking.test.ts
Normal file
@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
345
apps/frontend/src/__tests__/utils/export.test.ts
Normal file
345
apps/frontend/src/__tests__/utils/export.test.ts
Normal file
@ -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> = {}): 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user