Compare commits

..

25 Commits

Author SHA1 Message Date
David
fe7cd1f792 Merge branch 'dev' into preprod
Some checks failed
CD Preprod / Backend — Lint (push) Successful in 10m24s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m0s
CD Preprod / Backend — Unit Tests (push) Failing after 5m33s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m33s
CD Preprod / Backend — Integration Tests (push) Has been skipped
CD Preprod / Build Backend (push) Has been skipped
CD Preprod / Build Frontend (push) Has been skipped
CD Preprod / Deploy to Preprod (push) Has been skipped
CD Preprod / Smoke Tests (push) Has been skipped
CD Preprod / Notify Success (push) Has been skipped
CD Preprod / Notify Failure (push) Has been skipped
2026-04-04 17:02:39 +02:00
David
14c5073b12 fix types check
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Frontend — Lint & Type-check (push) Successful in 10m57s
Dev CI / Backend — Unit Tests (push) Successful in 10m12s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
2026-04-04 15:28:13 +02:00
David
fca1cf051a 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
2026-04-04 15:08:53 +02:00
David
62698de952 Merge branch 'cicd' into dev
Some checks failed
Dev CI / Backend — Lint (push) Successful in 10m24s
Dev CI / Frontend — Lint & Type-check (push) Successful in 10m52s
Dev CI / Frontend — Unit Tests (push) Failing after 5m50s
Dev CI / Backend — Unit Tests (push) Successful in 10m12s
Dev CI / Notify Failure (push) Has been skipped
# Conflicts:
#	apps/backend/src/application/auth/auth.service.ts
#	apps/backend/src/application/dto/subscription.dto.ts
#	apps/backend/src/application/services/subscription.service.ts
#	apps/backend/src/domain/value-objects/plan-feature.vo.ts
#	apps/backend/src/domain/value-objects/subscription-plan.vo.ts
#	apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts
#	apps/backend/src/infrastructure/stripe/stripe.adapter.ts
#	apps/frontend/e2e/booking-workflow.spec.ts
2026-04-04 14:21:15 +02:00
David
1d248b3cc9 fix test 2026-04-04 14:18:41 +02:00
David
711aca5f40 fix(cicd): sync corrected pipelines from cicd branch
Some checks failed
Dev CI / Frontend — Lint & Type-check (push) Failing after 6m12s
Dev CI / Frontend — Unit Tests (push) Has been skipped
Dev CI / Backend — Lint (push) Successful in 10m24s
Dev CI / Backend — Unit Tests (push) Failing after 5m30s
Dev CI / Notify Failure (push) Has been skipped
2026-04-04 13:16:46 +02:00
David
1fcf5d0032 fix(cicd): rewrite all pipelines — fix npm install, health endpoints, prod security gate 2026-04-04 13:16:40 +02:00
David
3ba87fbb42 revert: restore root-level docs mistakenly deleted
Some checks failed
Dev CI / Quality (${{ matrix.app }}) (frontend) (push) Failing after 6m16s
Dev CI / Quality (${{ matrix.app }}) (backend) (push) Successful in 10m26s
Dev CI / Unit Tests (${{ matrix.app }}) (backend) (push) Has been skipped
Dev CI / Unit Tests (${{ matrix.app }}) (frontend) (push) Has been skipped
Dev CI / Notify Failure (push) Has been skipped
2026-04-04 13:02:27 +02:00
David
9a5c8c92e0 chore: remove stale root-level docs (already in docs/installation/)
Some checks are pending
Dev CI / Quality (${{ matrix.app }}) (backend) (push) Waiting to run
Dev CI / Quality (${{ matrix.app }}) (frontend) (push) Waiting to run
Dev CI / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
Dev CI / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
Dev CI / Notify Failure (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:58:29 +02:00
David
08787c89c8 chore: sync full codebase from cicd branch
Some checks failed
Dev CI / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
Dev CI / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
Dev CI / Notify Failure (push) Blocked by required conditions
Dev CI / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
Dev CI / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
Aligns dev with the complete application codebase (cicd branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:56:16 +02:00
David
8400d203e8 feat(cicd): sync CI/CD pipeline from cicd branch
Some checks failed
Dev CI / Unit Tests (${{ matrix.app }}) (backend) (push) Blocked by required conditions
Dev CI / Unit Tests (${{ matrix.app }}) (frontend) (push) Blocked by required conditions
Dev CI / Notify Failure (push) Blocked by required conditions
Dev CI / Quality (${{ matrix.app }}) (backend) (push) Has been cancelled
Dev CI / Quality (${{ matrix.app }}) (frontend) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:52:40 +02:00
David
da93e86756 feat(cicd): add complete CI/CD pipeline for dev/preprod/prod (Hetzner k3s)
- ci.yml: dev branch CI — lint, type-check, unit tests (~5min)
- pr-checks.yml: PR gate to preprod (+ integration tests) and main
- cd-preprod.yml: full preprod pipeline — quality → integration → docker → deploy → smoke tests
- cd-main.yml: prod pipeline — promote Scaleway preprod image → kubectl rollout on k3s
- rollback.yml: emergency rollback (kubectl undo or specific tag, Portainer for preprod)
- docs: replace GHCR references with Scaleway registry in Hetzner k8s manifests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:52:14 +02:00
David
26a3412658 feat(admin): add 3-dot action menus, document deletion, and company email in CSV configs
- Bookings: replace static action cell with vertical dots menu (view details modal, validate transfer, delete)
- Documents: replace download button with dots menu (download, delete) + new admin DELETE endpoint bypassing status/ownership restrictions
- CSV rates: show company email (from upload form metadata) in active configs table, fix header layout (title left, reload right, same line)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 23:43:47 +02:00
David
74221d576e feat(contact): wire contact form to real backend — sends email to contact@xpeditis.com 2026-04-02 14:19:50 +02:00
David
ed0f43ba32 fix(contact): remove card links, fix support email, single Paris office, remove Europe map section 2026-04-02 13:47:49 +02:00
David
317de48765 feat: fix auth flows — reset password, 2-step register, remember me, SIRET
- Backend: add forgot-password and reset-password endpoints with token-based
  flow (1h expiry, secure random token, email via existing EmailPort)
- Backend: add PasswordResetTokenOrmEntity + migration
- Backend: add siret field to RegisterOrganizationDto and pass it to org creation
- Backend: remove MinLength(12) from LoginDto.password (wrong for login use case)
- Backend: add rememberMe to LoginDto (optional, informational)
- Frontend: register page rewritten as 2-step flow (account info → org info)
  with SIRET field, Suspense wrapper, "Centre d'aide" link removed
- Frontend: forgot-password page rewritten in French, matching login style
- Frontend: reset-password page rewritten in French, matching login style, with Suspense
- Frontend: remember me now works — localStorage when checked, sessionStorage otherwise
- Frontend: login page removes "Centre d'aide" link, connects rememberMe to login
- Frontend: auth-context and api/auth updated to pass rememberMe through full chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:10:08 +02:00
David
6a38c236b2 feat: add centralized logging stack with log exporter
- Add Loki + Promtail + Grafana logging infrastructure
- Add log-exporter service (REST API to query logs)
- Add docker-compose.logging.yml (standalone logging stack)
- Add docker-compose.full.yml (merged dev + logging in one file)
- Update docker-compose.dev.yml with network labels for Promtail
- Add admin logs dashboard page
- Fix invitation DTO and users settings page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:46:10 +02:00
David
e1f813bd92 fix documentation et landing page 2026-04-01 20:33:22 +02:00
David
0e4c0d7785 fix email and documentation api 2026-04-01 19:51:57 +02:00
David
ccc64b939a fix documentation et api key 2026-03-31 16:19:35 +02:00
David
6adcb2b9f8 fix docs 2026-03-26 18:08:28 +01:00
David
420e52311c fix system payment and other missing 2026-03-19 19:04:31 +01:00
David
230d06dc98 payment methode 2026-03-18 15:11:09 +01:00
David
1c6edb9d41 adding middleware for auth 2026-03-17 18:39:07 +01:00
David
7f31aa59a9 fix contact 2026-03-17 11:58:29 +01:00
17 changed files with 1319 additions and 5 deletions

View File

@ -5,7 +5,20 @@
*/
import { Subscription } from '@domain/entities/subscription.entity';
import { SubscriptionOrmEntity } from '../entities/subscription.orm-entity';
import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity';
/** Maps canonical domain plan names back to the values stored in the DB. */
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {
FREE: 'BRONZE',
STARTER: 'SILVER',
PRO: 'GOLD',
ENTERPRISE: 'PLATINIUM',
// Pass-through for any value already in ORM format
BRONZE: 'BRONZE',
SILVER: 'SILVER',
GOLD: 'GOLD',
PLATINIUM: 'PLATINIUM',
};
export class SubscriptionOrmMapper {
/**
@ -17,7 +30,7 @@ export class SubscriptionOrmMapper {
orm.id = props.id;
orm.organizationId = props.organizationId;
orm.plan = props.plan;
orm.plan = DOMAIN_TO_ORM_PLAN[props.plan] ?? 'BRONZE';
orm.status = props.status;
orm.stripeCustomerId = props.stripeCustomerId;
orm.stripeSubscriptionId = props.stripeSubscriptionId;

View File

@ -77,7 +77,7 @@ test.describe('Complete Booking Workflow', () => {
// Step 4: Select a Rate and Create Booking
await test.step('Select Rate and Create Booking', async () => {
// Select first available rate
await page.locator('.rate-card').first().click('button:has-text("Book")');
await page.locator('.rate-card').first().locator('button:has-text("Book")').click();
// Should navigate to booking form
await expect(page).toHaveURL(/.*bookings\/create/);

View 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);

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -44,6 +44,7 @@
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@types/file-saver": "^2.0.7",
"@types/jest": "^29.5.12",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
@ -2767,6 +2768,52 @@
"@types/istanbul-lib-report": "*"
}
},
"node_modules/@types/jest": {
"version": "29.5.14",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/jest/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@types/jest/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@types/jest/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsdom": {
"version": "20.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",

View File

@ -8,7 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"test": "jest",
"test:watch": "jest --watch",
"test:e2e": "playwright test"
@ -49,6 +49,7 @@
"@playwright/test": "^1.56.0",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@types/jest": "^29.5.12",
"@types/file-saver": "^2.0.7",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",

View 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']);
});
});
});

View 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();
});
});
});

View 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);
});
});
});

View 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');
});
});

View 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'
);
});
});
});

View 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 {};

View 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());
});
});
});

View 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');
});
});

View File

@ -31,5 +31,13 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": [
"node_modules",
"src/__tests__",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.spec.ts",
"**/*.spec.tsx",
"jest.setup.ts"
]
}

View File

@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom", "node"],
"jsx": "react-jsx"
},
"include": [
"next-env.d.ts",
"jest.setup.ts",
"src/__tests__/**/*.ts",
"src/__tests__/**/*.tsx",
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"exclude": ["node_modules"]
}

69
package-lock.json generated Normal file
View File

@ -0,0 +1,69 @@
{
"name": "xpeditis",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xpeditis",
"version": "0.1.0",
"license": "UNLICENSED",
"devDependencies": {
"@types/node": "^20.10.0",
"prettier": "^3.1.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}